Compare commits

...

389 Commits

Author SHA1 Message Date
hank
2e14315e3b New translations en.po (Chinese Traditional, Hong Kong) 2025-07-08 21:37:13 -04:00
hank
da3543017d New translations en.po (Croatian) 2025-07-08 21:37:12 -04:00
hank
e2b9144cef New translations en.po (Persian) 2025-07-08 21:37:11 -04:00
hank
d897325933 New translations en.po (Icelandic) 2025-07-08 21:37:10 -04:00
hank
3382ff89e7 New translations en.po (Vietnamese) 2025-07-08 21:37:09 -04:00
hank
d07dd668e6 New translations en.po (Chinese Traditional) 2025-07-08 21:37:09 -04:00
hank
652c6f3303 New translations en.po (Chinese Simplified) 2025-07-08 21:37:08 -04:00
hank
05134cb354 New translations en.po (Ukrainian) 2025-07-08 21:37:07 -04:00
hank
397fdfd68b New translations en.po (Turkish) 2025-07-08 21:37:06 -04:00
hank
278e066c6f New translations en.po (Swedish) 2025-07-08 21:37:05 -04:00
hank
6573a599f6 New translations en.po (Slovenian) 2025-07-08 21:37:04 -04:00
hank
059575dc83 New translations en.po (Russian) 2025-07-08 21:37:03 -04:00
hank
40f55f6959 New translations en.po (Portuguese) 2025-07-08 21:37:02 -04:00
hank
e586aeb17a New translations en.po (Polish) 2025-07-08 21:37:01 -04:00
hank
dc3f2fdb51 New translations en.po (Norwegian) 2025-07-08 21:37:00 -04:00
hank
a8bcb2022b New translations en.po (Dutch) 2025-07-08 21:36:59 -04:00
hank
9db2daa640 New translations en.po (Korean) 2025-07-08 21:36:58 -04:00
hank
0364c59ae2 New translations en.po (Japanese) 2025-07-08 21:36:57 -04:00
hank
741c83d476 New translations en.po (Italian) 2025-07-08 21:36:56 -04:00
hank
f061352ddc New translations en.po (Hungarian) 2025-07-08 21:36:55 -04:00
hank
fa77ddfffe New translations en.po (Greek) 2025-07-08 21:36:54 -04:00
hank
27f4a648d1 New translations en.po (German) 2025-07-08 21:36:53 -04:00
hank
d0ff1ccc16 New translations en.po (Danish) 2025-07-08 21:36:52 -04:00
hank
8edb928e50 New translations en.po (Czech) 2025-07-08 21:36:51 -04:00
hank
97e31dd5a9 New translations en.po (Bulgarian) 2025-07-08 21:36:50 -04:00
hank
181bdab303 New translations en.po (Arabic) 2025-07-08 21:36:49 -04:00
hank
30756bb079 New translations en.po (Spanish) 2025-07-08 21:36:48 -04:00
hank
a0540821db New translations en.po (French) 2025-07-08 21:36:47 -04:00
hank
1779a056c3 New translations en.po (Chinese Traditional, Hong Kong) 2025-07-08 20:09:24 -04:00
hank
258c70f2d8 New translations en.po (Croatian) 2025-07-08 20:09:23 -04:00
hank
ea50c711b2 New translations en.po (Persian) 2025-07-08 20:09:22 -04:00
hank
b30d957b38 New translations en.po (Icelandic) 2025-07-08 20:09:21 -04:00
hank
e6abfc6a5d New translations en.po (Vietnamese) 2025-07-08 20:09:20 -04:00
hank
7d356d6edb New translations en.po (Chinese Traditional) 2025-07-08 20:09:19 -04:00
hank
30766df9fa New translations en.po (Chinese Simplified) 2025-07-08 20:09:18 -04:00
hank
eaf992f3f1 New translations en.po (Ukrainian) 2025-07-08 20:09:17 -04:00
hank
27b81aa489 New translations en.po (Turkish) 2025-07-08 20:09:16 -04:00
hank
5a903f472e New translations en.po (Swedish) 2025-07-08 20:09:15 -04:00
hank
4985474b0f New translations en.po (Slovenian) 2025-07-08 20:09:14 -04:00
hank
0632db3c19 New translations en.po (Russian) 2025-07-08 20:09:13 -04:00
hank
6980a4ef86 New translations en.po (Portuguese) 2025-07-08 20:09:12 -04:00
hank
ee22bf8bcb New translations en.po (Polish) 2025-07-08 20:09:11 -04:00
hank
ae4c402b99 New translations en.po (Norwegian) 2025-07-08 20:09:10 -04:00
hank
7281094dc3 New translations en.po (Dutch) 2025-07-08 20:09:09 -04:00
hank
699e81eb1c New translations en.po (Korean) 2025-07-08 20:09:08 -04:00
hank
a6065b6a19 New translations en.po (Japanese) 2025-07-08 20:09:07 -04:00
hank
b278c92e54 New translations en.po (Italian) 2025-07-08 20:09:06 -04:00
hank
83d41ed1f9 New translations en.po (Hungarian) 2025-07-08 20:09:05 -04:00
hank
7fa39a120f New translations en.po (Greek) 2025-07-08 20:09:04 -04:00
hank
4b59748c0c New translations en.po (German) 2025-07-08 20:09:03 -04:00
hank
ab2ef5bcfc New translations en.po (Danish) 2025-07-08 20:09:02 -04:00
hank
2524b0d359 New translations en.po (Czech) 2025-07-08 20:09:01 -04:00
hank
f84b159309 New translations en.po (Bulgarian) 2025-07-08 20:09:00 -04:00
hank
d5e5f8678a New translations en.po (Arabic) 2025-07-08 20:08:59 -04:00
hank
c91e0a9faa New translations en.po (Spanish) 2025-07-08 20:08:58 -04:00
hank
c6a6a353a5 New translations en.po (French) 2025-07-08 20:08:57 -04:00
hank
7d1f8f5cae New translations en.po (Czech) 2025-05-28 04:23:36 -04:00
hank
64a5ef4dfe New translations en.po (Portuguese) 2025-05-26 23:09:11 -04:00
hank
030d2b6684 New translations en.po (Greek) 2025-05-26 17:03:45 -04:00
hank
938e87db8b New translations en.po (German) 2025-05-26 08:12:11 -04:00
hank
31ed898a58 New translations en.po (Greek) 2025-05-24 15:52:00 -04:00
hank
1b8ad81046 New translations en.po (Danish) 2025-05-23 16:31:05 -04:00
hank
ff3874491c New translations en.po (Polish) 2025-05-12 08:30:31 -04:00
hank
fe81553c6a New translations en.po (Greek) 2025-05-06 17:35:17 -04:00
hank
6e8b3f6265 New translations en.po (Korean) 2025-05-05 10:36:01 -04:00
hank
9c87911a7f New translations en.po (Arabic) 2025-05-05 10:35:59 -04:00
hank
082cbb36e7 New translations en.po (Chinese Traditional) 2025-05-03 05:12:43 -04:00
hank
8347cf0e5c New translations en.po (Arabic) 2025-05-02 20:24:44 -04:00
hank
abfafae0d9 New translations en.po (Korean) 2025-04-29 10:28:27 -04:00
hank
6496f35915 New translations en.po (Japanese) 2025-04-28 04:23:06 -04:00
hank
7fee3da2e8 New translations en.po (Ukrainian) 2025-04-27 07:20:55 -04:00
hank
443e203d9c New translations en.po (German) 2025-04-27 06:25:22 -04:00
hank
24875b5733 New translations en.po (Chinese Traditional, Hong Kong) 2025-04-26 17:53:34 -04:00
hank
66eaf45c79 New translations en.po (Croatian) 2025-04-26 17:53:33 -04:00
hank
2b9fb8b1ec New translations en.po (Persian) 2025-04-26 17:53:33 -04:00
hank
06e0ae4733 New translations en.po (Icelandic) 2025-04-26 17:53:32 -04:00
hank
4cc0bfeb0f New translations en.po (Vietnamese) 2025-04-26 17:53:31 -04:00
hank
320d0e5d97 New translations en.po (Chinese Traditional) 2025-04-26 17:53:30 -04:00
hank
67953bb6af New translations en.po (Chinese Simplified) 2025-04-26 17:53:29 -04:00
hank
123c57ded9 New translations en.po (Ukrainian) 2025-04-26 17:53:28 -04:00
hank
a7991ca184 New translations en.po (Turkish) 2025-04-26 17:53:27 -04:00
hank
1b01410533 New translations en.po (Swedish) 2025-04-26 17:53:26 -04:00
hank
1687919f31 New translations en.po (Slovenian) 2025-04-26 17:53:25 -04:00
hank
6046dbb727 New translations en.po (Russian) 2025-04-26 17:53:24 -04:00
hank
2505b16faa New translations en.po (Portuguese) 2025-04-26 17:53:23 -04:00
hank
c4352e65fb New translations en.po (Polish) 2025-04-26 17:53:22 -04:00
hank
7b2e9ccdcb New translations en.po (Norwegian) 2025-04-26 17:53:21 -04:00
hank
75a87cddbf New translations en.po (Korean) 2025-04-26 17:53:20 -04:00
hank
bda7b9b48d New translations en.po (Japanese) 2025-04-26 17:53:19 -04:00
hank
65d6ec1918 New translations en.po (Italian) 2025-04-26 17:53:18 -04:00
hank
0e16fca07f New translations en.po (Hungarian) 2025-04-26 17:53:17 -04:00
hank
b7e574f379 New translations en.po (German) 2025-04-26 17:53:16 -04:00
hank
113f1833e1 New translations en.po (Danish) 2025-04-26 17:53:15 -04:00
hank
a0d3e60d29 New translations en.po (Czech) 2025-04-26 17:53:14 -04:00
hank
df7f12bfec New translations en.po (Bulgarian) 2025-04-26 17:53:13 -04:00
hank
02e88d99e5 New translations en.po (Spanish) 2025-04-26 17:53:12 -04:00
hank
a11ceb36d1 New translations en.po (French) 2025-04-26 17:53:11 -04:00
hank
d3a373338d New translations en.po (Arabic) 2025-04-26 17:53:10 -04:00
hank
e1ef05da8c New translations en.po (Dutch) 2025-04-26 17:53:09 -04:00
hank
c01bfd9332 New translations en.po (Arabic) 2025-04-23 13:19:56 -04:00
hank
209e82aed2 New translations en.po (Dutch) 2025-04-14 09:04:03 -04:00
hank
c66e740d52 New translations en.po (Arabic) 2025-04-05 21:52:03 -04:00
hank
551ae49c2e New translations en.po (French) 2025-03-28 17:04:47 -04:00
hank
906daa6f2a New translations en.po (French) 2025-03-26 06:03:01 -04:00
hank
49e55943c1 New translations en.po (Russian) 2025-03-25 04:32:17 -04:00
hank
9e75cbc1ef New translations en.po (Italian) 2025-03-22 11:29:03 -04:00
hank
79190a2c51 New translations en.po (Norwegian) 2025-03-17 09:40:03 -04:00
hank
32960c7c35 New translations en.po (Chinese Simplified) 2025-03-15 04:13:35 -04:00
hank
cdff974a8a New translations en.po (Ukrainian) 2025-03-15 00:14:37 -04:00
hank
20a6ef129c New translations en.po (Czech) 2025-03-13 20:50:03 -04:00
hank
d1e5310f83 New translations en.po (Japanese) 2025-03-13 06:13:10 -04:00
hank
152173eab5 New translations en.po (Korean) 2025-03-07 05:06:11 -05:00
hank
c2aec69638 New translations en.po (Ukrainian) 2025-03-06 07:37:37 -05:00
hank
16c97fe77b New translations en.po (Chinese Traditional, Hong Kong) 2025-03-06 02:27:48 -05:00
hank
bed5bc2791 New translations en.po (Croatian) 2025-03-06 02:27:47 -05:00
hank
424c7aceff New translations en.po (Persian) 2025-03-06 02:27:46 -05:00
hank
8eb101e714 New translations en.po (Vietnamese) 2025-03-06 02:27:45 -05:00
hank
f2e69e2a80 New translations en.po (Chinese Traditional) 2025-03-06 02:27:44 -05:00
hank
41b4dbce98 New translations en.po (Chinese Simplified) 2025-03-06 02:27:43 -05:00
hank
c3432fc7b5 New translations en.po (Ukrainian) 2025-03-06 02:27:42 -05:00
hank
b167244e28 New translations en.po (Turkish) 2025-03-06 02:27:40 -05:00
hank
bcca6a5a9d New translations en.po (Swedish) 2025-03-06 02:27:39 -05:00
hank
6b661b4878 New translations en.po (Slovenian) 2025-03-06 02:27:37 -05:00
hank
bc537edb73 New translations en.po (Russian) 2025-03-06 02:27:36 -05:00
hank
561a3e8aaf New translations en.po (Polish) 2025-03-06 02:27:34 -05:00
hank
f173ea37da New translations en.po (Norwegian) 2025-03-06 02:27:33 -05:00
hank
0d9f2ba06a New translations en.po (Dutch) 2025-03-06 02:27:32 -05:00
hank
4e772e6008 New translations en.po (Korean) 2025-03-06 02:27:31 -05:00
hank
b959d97502 New translations en.po (Italian) 2025-03-06 02:27:30 -05:00
hank
735cbe2cf0 New translations en.po (German) 2025-03-06 02:27:29 -05:00
hank
0f5a470495 New translations en.po (Danish) 2025-03-06 02:27:28 -05:00
hank
a42e99370e New translations en.po (Czech) 2025-03-06 02:27:27 -05:00
hank
abfae78af1 New translations en.po (Bulgarian) 2025-03-06 02:27:26 -05:00
hank
e36c40c4a9 New translations en.po (Arabic) 2025-03-06 02:27:25 -05:00
hank
bf7b2ae598 New translations en.po (Spanish) 2025-03-06 02:27:24 -05:00
hank
f71ee4b058 New translations en.po (French) 2025-03-06 02:27:23 -05:00
hank
f2466eb37d New translations en.po (Hungarian) 2025-03-06 02:27:22 -05:00
hank
7244c7130b New translations en.po (Japanese) 2025-03-06 02:27:21 -05:00
hank
f2e84a9d3e New translations en.po (Portuguese) 2025-03-06 02:27:20 -05:00
hank
04a1ee5e4e New translations en.po (Icelandic) 2025-03-06 02:27:19 -05:00
hank
9b83088897 New translations en.po (Norwegian) 2025-03-05 15:49:42 -05:00
hank
13b30aa255 New translations en.po (Russian) 2025-03-01 13:24:35 -05:00
hank
2bd25e9e8d New translations en.po (Ukrainian) 2025-03-01 08:20:09 -05:00
hank
4fe192bf28 New translations en.po (Ukrainian) 2025-03-01 07:11:52 -05:00
hank
2056ae285f New translations en.po (Spanish) 2025-02-28 12:20:35 -05:00
hank
a02e7a0a69 New translations en.po (Spanish) 2025-02-28 11:07:32 -05:00
hank
b1a9e90034 New translations en.po (Chinese Traditional, Hong Kong) 2025-02-27 17:31:32 -05:00
hank
20916fab3e New translations en.po (Croatian) 2025-02-27 17:31:31 -05:00
hank
0c777cca72 New translations en.po (Persian) 2025-02-27 17:31:31 -05:00
hank
44e30ad429 New translations en.po (Vietnamese) 2025-02-27 17:31:30 -05:00
hank
9e30786dda New translations en.po (Chinese Traditional) 2025-02-27 17:31:29 -05:00
hank
1f677773e7 New translations en.po (Chinese Simplified) 2025-02-27 17:31:28 -05:00
hank
882289da91 New translations en.po (Ukrainian) 2025-02-27 17:31:26 -05:00
hank
fbbc4eff27 New translations en.po (Turkish) 2025-02-27 17:31:25 -05:00
hank
b78231f677 New translations en.po (Swedish) 2025-02-27 17:31:24 -05:00
hank
332e2d14a9 New translations en.po (Slovenian) 2025-02-27 17:31:23 -05:00
hank
e088b88c84 New translations en.po (Russian) 2025-02-27 17:31:22 -05:00
hank
09840f95d9 New translations en.po (Polish) 2025-02-27 17:31:21 -05:00
hank
de03bb658a New translations en.po (Norwegian) 2025-02-27 17:31:19 -05:00
hank
bcadfeb729 New translations en.po (Dutch) 2025-02-27 17:31:18 -05:00
hank
ec582a9171 New translations en.po (Korean) 2025-02-27 17:31:17 -05:00
hank
0c3eaefc90 New translations en.po (Italian) 2025-02-27 17:31:16 -05:00
hank
d6b5866f90 New translations en.po (German) 2025-02-27 17:31:15 -05:00
hank
2ba629e4b4 New translations en.po (Danish) 2025-02-27 17:31:14 -05:00
hank
6e9341b7ff New translations en.po (Czech) 2025-02-27 17:31:13 -05:00
hank
89499a341b New translations en.po (Bulgarian) 2025-02-27 17:31:12 -05:00
hank
9c88845798 New translations en.po (Arabic) 2025-02-27 17:31:10 -05:00
hank
c9f65f63e6 New translations en.po (Spanish) 2025-02-27 17:31:09 -05:00
hank
996620c8e0 New translations en.po (French) 2025-02-27 17:31:08 -05:00
hank
626c1358d9 New translations en.po (Hungarian) 2025-02-27 17:31:07 -05:00
hank
19dd39b7db New translations en.po (Japanese) 2025-02-27 17:31:06 -05:00
hank
d482b50e31 New translations en.po (Portuguese) 2025-02-27 17:31:05 -05:00
hank
f61ec49c76 New translations en.po (Icelandic) 2025-02-27 17:31:03 -05:00
henrygd
2b43ba3cbe i18n: update language files 2025-02-27 17:20:12 -05:00
ArsFy
b2b1a0b6ea i18n: new Chinese translations 2025-02-27 17:19:04 -05:00
stanol
b11d0aae61 i18n: new Ukrainian translations 2025-02-27 17:18:35 -05:00
henrygd
2b73d8845a feat: allow x min downtime before alerting (#595, #625)
- splits alerts package into three files. status alerts were not
modified aside from updating to slices.Delete method
2025-02-27 17:12:25 -05:00
henrygd
41e3e3d760 chore: update .gitignore 2025-02-27 00:37:29 -05:00
henrygd
c22b57ce67 refactor(dev): use ENV var to enable hub dev mode 2025-02-26 18:42:34 -05:00
hank
23bee0aa7c build: add go patch version in go.mod 2025-02-26 15:21:54 -05:00
henrygd
0c2629f57e i18n: update language files 2025-02-23 21:07:25 -05:00
harupong
a4b689e8f1 i18n: new Japanese translations 2025-02-23 21:05:52 -05:00
Roy W. Andersen
0c5841133b i18n: new Norwegian translations 2025-02-23 21:03:47 -05:00
henrygd
78a645fa05 i18n: Portuguese translations by PoetaGA 2025-02-23 21:02:24 -05:00
aronov
f1208a9f00 i18n: progress on Icelandic 2025-02-23 20:55:14 -05:00
Alexander Mnich
aeb5f1424b i18n: update German 2025-02-23 20:54:08 -05:00
henrygd
4a1fb513c5 i18n: update language files 2025-02-23 20:53:08 -05:00
henrygd
e5a66cc156 feat(add-system): add link to binary install docs in copy button 2025-02-23 20:39:57 -05:00
Alexander Mnich
aef3b3e610 publish docker images to ghcr.io (#615) 2025-02-22 17:58:49 -05:00
henrygd
1d13ecd8ec ui: truncate full path of host if using unix socket 2025-02-22 17:58:30 -05:00
henrygd
6404895a47 refactor: centralize chart max/avg value state to global store 2025-02-22 15:28:07 -05:00
henrygd
ba7db28e80 test(gpu): add case for AMD multi-GPU and different power property (#414) 2025-02-22 12:45:47 -05:00
henrygd
6b41a98338 gpu: add tests and refactor to support amd on windows 2025-02-21 00:56:40 -05:00
henrygd
b958e9eefe fix: agent network not set to unix if addr provided by env var 2025-02-21 00:56:22 -05:00
henrygd
baf56fe83b fix: refresh interfaces if agent starts before network online (#466) 2025-02-21 00:21:47 -05:00
henrygd
96f9128d1a agent: add lock for gatherStats 2025-02-21 00:20:41 -05:00
henrygd
1fb60e05d7 i18n: add Norwegian to web ui 2025-02-19 21:54:35 -05:00
Roy W. Andersen
a9a9a932a6 New translations (Norwegian) 2025-02-19 21:42:53 -05:00
henrygd
e0a1a49a8f chore: update language files 2025-02-19 21:06:55 -05:00
henrygd
79eb42d04d add CGO_ENABLED=0 back to docker images / goreleaser config 2025-02-19 21:05:31 -05:00
henrygd
25b70af196 feat: add unix socket support for system connections 2025-02-19 20:29:45 -05:00
henrygd
c12b27afb5 refactor: update system table and improve add-system dialog 2025-02-19 20:28:45 -05:00
henrygd
7485f79071 refactor(agent): refactor option parsing logic for agent command 2025-02-19 19:39:24 -05:00
henrygd
d170e7a00d feat(agent): NETWORK env var and support for multiple keys
- merges agent.Run with agent.NewAgent
- separates StartServer method
- bumps go version to 1.24
- add tests
2025-02-19 00:32:27 -05:00
Alexander Mnich
1a6a2a64f2 implement agent startup arguments and help message (#581) 2025-02-19 00:19:49 -05:00
henrygd
646b899851 i18n: add hungarian
Co-authored-by: mathis <37186532+maaaathis@users.noreply.github.com>
2025-02-14 21:08:49 -05:00
Roland
821e2e3a78 New translations (Hungarian)
Co-authored-by: skidoodle <contact@albert.lol>
2025-02-14 20:47:15 -05:00
ikumasudo
9be3fcb8ca New translations (Japanese) 2025-02-14 20:36:48 -05:00
henrygd
f271b5a56c issues: add note about zfs to bug report template
a
2025-02-14 14:59:04 -05:00
Kal McFate
4f80a58929 Update add-system.tsx docker-compose.yml template. (#565)
Adding `version: "3"` makes this compatible with more recent docker tools.
2025-02-13 19:52:49 -05:00
henrygd
2ab2cc83de refactor(hub): embed pocketbase fields in hub struct 2025-02-11 19:18:36 -05:00
henrygd
3376a97bea refactor(hub): replace sync.map with app.store 2025-02-11 19:18:28 -05:00
henrygd
0c54f95546 feat: auto-login if one oauth provider and password auth disabled 2025-02-11 17:44:18 -05:00
henrygd
5ea6eb08a1 feat: PRIMARY_SENSOR env var to choose dashboard temp 2025-02-11 15:11:46 -05:00
henrygd
6b2a9463ca feat: edit system dialog in dashboard 2025-02-11 14:12:25 -05:00
henrygd
a94cfff965 fix: web ui should only respond to get requests 2025-02-11 11:04:19 -05:00
Daniel Nikoloski
1f69937572 Initial beszel-hub k8s chart (#537)
* Initial beszel-hub k8s chart

* Moved beszel-hub chart in supplemental
2025-02-10 18:29:52 -05:00
henrygd
aa3de511b9 refactor: get rid of console warnings on dashboard 2025-02-08 19:40:32 -05:00
henrygd
3afab00937 feat: display peak GPU usage in dashboard 2025-02-08 19:24:38 -05:00
henrygd
e6054058b9 feat: add temperatures to dashboard
- Refactor temperature related code and move to standalone function
2025-02-07 21:27:15 -05:00
Henry Dollman
31d52d5e15 update style of add system dialog 2025-02-06 20:01:04 -05:00
Venipa
44d930a700 feat(AddAgent): added docker run command copy (#534) 2025-02-06 19:41:01 -05:00
Henry Dollman
d7ada1b1c5 add manifest.json with icon for home screen (closes #525) 2025-02-05 20:27:23 -05:00
Henry Dollman
1daf35406a auth: ensure realtime subscriptions are cleared on logout 2025-02-05 20:24:39 -05:00
Henry Dollman
2216e40f04 chore: update go and js deps 2025-02-05 20:22:37 -05:00
Henry Dollman
f4480c7aa7 refactor: updateSystemList function 2025-02-05 20:21:57 -05:00
ompathak2004
5b478c11eb fix: prevent multiple concurrent fetches in updateSystemList function
fix: correctly reset isFetchingSystems flag in updateSystemList function
2025-02-05 19:04:18 -05:00
Henry Dollman
58085bf300 feature: support serving from subpath (#33)
Co-authored-by: Karthik T <karthikt.holmes+github@gmail.com>
2025-02-04 21:22:40 -05:00
Henry Dollman
ce171cf375 systemd: remove ProtectKernel=yes from unit file (invalid option) 2025-02-03 19:12:33 -05:00
Daniel Lo Nigro
e689f547ef Create Debian package for beszel-agent (#497)
* Add Debian package to build

Signed-off-by: Daniel Lo Nigro <git@d.sb>

* Fix Lintian issues

Signed-off-by: Daniel Lo Nigro <git@d.sb>

* - Add prerm script to stop and disable service
- Add security/sandboxing options to systemd unit

Signed-off-by: Daniel Lo Nigro <git@d.sb>

---------

Signed-off-by: Daniel Lo Nigro <git@d.sb>
2025-02-03 19:08:01 -05:00
Henry Dollman
5a8e8c1512 agent-install: add security options to systemd unit file 2025-02-03 19:06:03 -05:00
Henry Dollman
ff5eb07716 users: remove unused username field and require email (#24) 2025-02-03 18:29:02 -05:00
Henry Dollman
fdbbbc77b0 auth-form: red field border on error + firefox ux improvement (#24) 2025-02-03 17:59:39 -05:00
ompathak2004
20cba1b695 feat: update password max length to 72 characters 2025-02-04 01:48:25 +05:30
Henry Dollman
207d58a07e build: remove CGO_ENABLED=0 from docker build 2025-01-31 17:33:49 -05:00
Henry Dollman
0759a3607c supress pocketbase auto cancellation error 2025-01-31 17:16:21 -05:00
Henry Dollman
0b4742d064 update instructions for resetting password via docker 2025-01-31 15:19:18 -05:00
Henry Dollman
4557f18195 fix: only show GPU power chart if power data exists 2025-01-30 20:30:40 -05:00
Henry Dollman
83668e5727 fix(gpu): handle power for dedicated amd gpus (#414) 2025-01-30 20:28:31 -05:00
Henry Dollman
120aff0d18 config: prefix environment variables with BESZEL_AGENT_ (#502) 2025-01-29 20:13:07 -05:00
Henry Dollman
7170b24160 config: prefix environment variables with BESZEL_HUB_ (#502) 2025-01-29 19:25:12 -05:00
Henry Dollman
3441b39a02 update password reset instructions 2025-01-29 18:55:17 -05:00
Henry Dollman
31d306f8be Merge branch 'jetson' 2025-01-24 23:21:48 -05:00
hank
76347f25e5 fix(gpu): prevent nvidia-smi from running on tegra devices 2025-01-24 23:12:39 -05:00
hank
c157f38957 gpu: Add closure for Jetson and improve compatibility 2025-01-24 22:07:37 -05:00
Links
d185dfdef8 get Jetson GPU Information 2025-01-24 19:17:33 -05:00
hank
319a9895b0 New Crowdin updates (#462)
* New translations en.po (Korean)

* New translations en.po (Hungarian)

* New translations en.po (Chinese Traditional, Hong Kong)

Co-authored-by: skidoodle <contact@albert.lol>
Co-authored-by: Tony <TonyRL@users.noreply.github.com>
Co-authored-by: aroxu <30624945+aroxu@users.noreply.github.com>
2025-01-23 22:34:24 -05:00
Mikael Silvén
68dae3967d feat(release): add riscv agent builds to goreleaser config (#480)
This change adds a riscv build config to for the agent to goreleaser.
As far as I am aware, only freebsd and linux supports this, so ignore
mac and windows.
2025-01-23 15:54:10 -05:00
Henry Dollman
0a331524cc feat(release): add windows agent to goreleaser config 2025-01-22 16:19:33 -05:00
Henry Dollman
5b625db57c fix(systemd): Ensure agent starts after network is online (#466) 2025-01-21 20:25:07 -05:00
Henry Dollman
0943e01b71 feat(install): Default to gh.beszel.dev GitHub proxy for mainland China
- also removes CHINA_MAINLAND variable
2025-01-21 20:20:36 -05:00
Henry Dollman
cfda7d0740 Merge branch 'main' of https://github.com/Oaklight/beszel 2025-01-21 18:51:34 -05:00
Henry Dollman
4e3d198b7b fix(navigation): Update systems collection link to use collection name 2025-01-21 18:21:46 -05:00
Henry Dollman
a6a9719565 config(issues): Remove alternatives field in feature request 2025-01-20 13:22:23 -05:00
Peng Ding
ddb4f1c8f8 fix compatibility issue with sh. 2025-01-20 05:38:50 -06:00
Peng Ding
55d13c551a Enhance proxy handling in install scripts for GitHub mirrors 2025-01-20 05:27:58 -06:00
hank
fef30b1750 config(issues): Remove report vuln link since it's added automatically 2025-01-19 20:18:36 -05:00
hank
0cee9e4e4b docs(config): Update issue and discussion templates 2025-01-19 20:14:50 -05:00
hank
90378d09a3 Merge branch 'main' of https://github.com/svenvg93/beszel 2025-01-19 20:09:51 -05:00
Henry Dollman
7adf7ef549 config(goreleaser): rm CGO_ENABLED=0 and set releases to draft by default 2025-01-18 19:01:09 -05:00
Henry Dollman
ee6a456b66 chore(install-agent): formatting + rm unused variable 2025-01-18 18:09:02 -05:00
Nicolás Martínez Van der Looven
4789f48ad0 Add OpenWRT support for binary quick script installation (#445) 2025-01-18 17:41:21 -05:00
Sven van Ginkel
ea098fd61c Remove label 2025-01-18 10:16:25 +01:00
Sven van Ginkel
e7c214799a Add OS option 2025-01-18 10:16:01 +01:00
Sven van Ginkel
9cabc103e5 Add Templates 2025-01-18 10:12:28 +01:00
hank
be955e0122 New Crowdin updates (#409)
* New translations en.po (Italian)

* New translations en.po (Korean)

Co-authored-by: davtur19 <dav.tur19@gmail.com>
Co-authored-by: aroxu <30624945+aroxu@users.noreply.github.com>
2025-01-17 16:49:33 -05:00
Henry Dollman
8a0f2d61a8 style(auth-form): remove redundant placeholder class from password inputs 2025-01-17 16:45:12 -05:00
Alexander Mnich
48ed4abc02 remove lowercase of placeholders in auth-form 2025-01-17 11:46:59 +00:00
Henry Dollman
e8c680bda7 refactor: streamline key retrieval logic in agent.go 2025-01-16 17:49:20 -05:00
Henry Dollman
37c7a32c10 add funding.yml 2025-01-16 17:48:49 -05:00
Ben Morgenstren
1c4533f1f2 adding reading agent ssh key from file 2025-01-14 22:13:36 -05:00
Henry Dollman
5d88599c9a beszel-agent-update.service: avoid non-zero exit status and add logging (#397) 2025-01-14 20:31:50 -05:00
Henry Dollman
73427306d1 add zh-tw 2025-01-14 19:56:36 -05:00
hank
f24a7313d6 New Crowdin updates (#404)
* New translations en.po (Chinese Traditional)

Co-authored-by: Kin Lo <kinabcd@gmail.com>
2025-01-14 12:13:19 -05:00
Henry Dollman
ad55d1ca88 add Bulgarian and update locale references 2025-01-13 19:26:01 -05:00
hank
9c669d8833 New Crowdin updates (#372)
* New translations en.po (Bulgarian)

* New translations en.po (German)

Co-authored-by: vetyp <176803899+vetyp@users.noreply.github.com>
2025-01-10 15:39:01 -05:00
Henry Dollman
81fa4f16d6 ui: grey meters in systems table if system is not up (#370) 2025-01-06 13:22:51 -05:00
Henry Dollman
40cc1a875e release 0.9.1 2025-01-05 18:03:03 -05:00
Henry Dollman
1ac165d7d3 include stats in error log when encoding stats fails 2025-01-05 17:58:38 -05:00
Henry Dollman
9619e6cf89 update go dependencies 2025-01-05 17:57:43 -05:00
Henry Dollman
fc31cefd4c update initial settings with minimum log level and use new SetRandomPassword method 2025-01-05 17:56:42 -05:00
Henry Dollman
5fd9010b39 add slovenian and danish 2025-01-05 17:43:19 -05:00
hank
c2e3dd5ab1 New Crowdin updates (#352)
* New translations en.po (French)

* New translations en.po (Polish)

* New translations en.po (Spanish)

* New translations en.po (Danish)

Co-authored-by: Morten Johansen <mj@emsg.net>
Co-authored-by: Kamil <32385799+nkkfs@users.noreply.github.com>
2025-01-05 17:13:25 -05:00
Henry Dollman
8e531e6b3c fix: handle duplicate GPU names (#361) 2025-01-05 16:40:22 -05:00
Henry Dollman
527e6b57d5 remove CGO_ENABLED from build commands in Makefile 2025-01-05 12:27:53 -05:00
Chuangbo Li
245fa538e9 chore: upgrade gopsutil to fix memory leak on ARM Mac (#367) 2025-01-04 18:16:57 -05:00
Henry Dollman
e14a851398 fix: armv6l detection in install-agent script (#364) 2025-01-02 17:10:21 -05:00
Chuangbo Li
0c9bc47a3a refactor: simplify startSystemUpdateTicker using time.Tick (#347)
According to the Go 1.23 documentation, the garbage collector can now recover unreferenced tickers created with time.Tick, making time.NewTicker method unnecessary in most cases.

Reference:

* time.Tick: https://pkg.go.dev/time#Tick
2024-12-28 17:07:32 -05:00
Chuangbo Li
19b4477a75 fix: json.Unmarshal not resetting struct fields (#345)
The json.Unmarshal function was not properly resetting the Stats struct fields between iterations in the loop. This caused incorrect values to be retained from previous iterations, leading to unexpected behavior.

This commit fixes the issue by zero the struct in each loop iteration, ensures that each unmarshaling operation starts with a clean struct, preventing value carryover.
2024-12-28 17:06:37 -05:00
hank
558d051c42 New Crowdin updates (#330)
* New translations en.po (Slovenian)
* New translations en.po (Swedish)
* New translations en.po (Ukrainian)

Co-authored-by: thehijacker <thehijacker@gmail.com>
Co-authored-by: stanol <stanol777@gmail.com>
2024-12-27 11:11:05 -05:00
Arsierl
9c8528bae1 Update install-agent.sh script to support Alpine (OpenRC) (#320)
* Partial amendment

* Update install-agent.sh

* Update install-agent.sh

Fix if is_alpine function with user creation and permission issues

* Update install-agent.sh

Adding an Uninstallation Process to Alpine
Using deluser instead of userdel in Alpine
2024-12-18 17:41:15 -05:00
Henry Dollman
229ef19376 update readme 2024-12-17 20:00:16 -05:00
Henry Dollman
e5fb4d611a release 0.9.0 2024-12-17 18:55:02 -05:00
Henry Dollman
bc9dc9704c remove log from initial-settings migration 2024-12-17 18:54:52 -05:00
Henry Dollman
e88eb1a884 update docs link in command palette 2024-12-17 17:42:58 -05:00
Henry Dollman
d8f3206e8b update go dependencies 2024-12-17 17:25:01 -05:00
Henry Dollman
729d306157 update js dependencies 2024-12-17 17:24:38 -05:00
Henry Dollman
c35df48754 update language files 2024-12-17 17:20:44 -05:00
hank
0f97f37a79 New Crowdin updates (#329)
* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Czech)

* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Italian)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Dutch)

* New translations en.po (Polish)

* New translations en.po (Portuguese)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Croatian)

* New translations en.po (Chinese Traditional, Hong Kong)

* New translations en.po (Persian)
2024-12-17 17:17:33 -05:00
Henry Dollman
b08219dacf refactor agent gpu code to make it easier to add intel / jetson 2024-12-17 17:12:58 -05:00
Henry Dollman
dd10fb97c0 web ui style tweaks 2024-12-17 15:47:49 -05:00
Henry Dollman
87354df2de update po files, add farsi and swedish 2024-12-17 15:36:09 -05:00
hank
1bd04498b9 New Crowdin updates (#322) 2024-12-17 15:31:28 -05:00
Henry Dollman
52394bc99b update security policy 2024-12-16 14:42:10 -05:00
Henry Dollman
add85e9747 add generate-locales to makefile 2024-12-16 13:48:17 -05:00
Henry Dollman
e82986adff add dev tasks to makefile 2024-12-15 15:35:16 -05:00
Henry Dollman
f201267e4e generate first user username from email 2024-12-09 17:34:04 -05:00
Henry Dollman
9db41f8830 update translations 2024-12-09 17:29:03 -05:00
Henry Dollman
ba64c59632 add autoComplete="off" to login honeypot field 2024-12-09 17:07:43 -05:00
Henry Dollman
d2626d8337 update translations 2024-12-09 17:04:40 -05:00
hank
ded1090190 New Crowdin updates (#318)
* New translations en.po (Arabic)

* New translations en.po (Dutch)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
2024-12-09 16:24:45 -05:00
hank
1114baaaa0 New Crowdin updates (#313)
* New translations en.po (German)

* New translations en.po (Czech)

Co-authored-by: doluk <69309597+doluk@users.noreply.github.com>
Co-authored-by: NickAss512 <143963746+NickAss512@users.noreply.github.com>
2024-12-08 18:18:53 -05:00
Henry Dollman
cf13c1c671 Merge branch '0xMMMMMM-main' 2024-12-08 18:09:20 -05:00
Henry Dollman
e70de6a59e systems table updates
- component refactoring
- style updates for "view" menu and grid
2024-12-08 18:08:54 -05:00
0xMMMMMM
5110eaf10f refactor: reorganize systems table options into single dropdown
Combines view type, sort by and visible fields into a single dropdown menu for better organization.
2024-12-08 06:26:12 +07:00
0xMMMMMM
0234682720 feat: add grid view option for systems table
- Add toggle button to switch between table and grid layouts
- Implement card-based grid view with system metrics
- Add sort dropdown menu for grid view
- Display system status, metrics with icons and labels
- Improve handling of long system names
- Maintain consistent sorting and filtering between views
- Persist view preference in localStorage
2024-12-06 15:09:37 +07:00
Henry Dollman
80a7322fa1 update readme screenshot 2024-12-04 18:10:27 -05:00
Henry Dollman
59bdc0ce0d add USER_CREATION env var and update migrations 2024-12-04 17:36:36 -05:00
Spedon
a288d0925b chore: enhance script usability (#311)
- Add a `RestartSec` directive to the service configuration for both agent and hub scripts

Signed-off-by: Spedon Wen <realsped0n@outlook.com>
2024-12-03 17:29:19 -05:00
hank
e7d2f0d82b New Crowdin updates (#308)
* New translations en.po

Co-authored-by: NickAss512 <143963746+NickAss512@users.noreply.github.com>
2024-12-02 15:14:20 -05:00
Henry Dollman
825d8269ff add Czech language and update locale files 2024-12-01 18:21:34 -05:00
hank
f7775d173a New Crowdin updates (#301)
* New translations en.po (Turkish)

* New translations en.po (Czech)

Co-authored-by: Ramazan Sancar <ramazansancar4545@gmail.com>
Co-authored-by: NickAss512 <@NickAss512>
2024-12-01 16:11:25 -05:00
Henry Dollman
58bced5f09 rm 'auth providers' links - dedicated page removed from pocketbase 2024-11-29 17:24:42 -05:00
Henry Dollman
6e08507dde remove todo comments 2024-11-29 17:16:40 -05:00
Henry Dollman
617a03fc15 upgrade go deps 2024-11-29 17:08:59 -05:00
Henry Dollman
f86bda304d pocketbase js updates 2024-11-29 17:03:31 -05:00
Henry Dollman
1d414e659b use auth logos from pocketbase 2024-11-29 16:57:18 -05:00
Henry Dollman
87f7390eca use new batch api for setting global alerts 2024-11-27 17:07:44 -05:00
Henry Dollman
ed01752546 update admin creation for pocketbase 0.23.0 2024-11-27 16:32:23 -05:00
Henry Dollman
46002a2171 remove echo dependency 2024-11-24 18:34:42 -05:00
Henry Dollman
14716d36a6 refactor go code for pocketbase 0.23.0 (#300) 2024-11-24 18:15:24 -05:00
Henry Dollman
b4bc8a31aa add check / reset for invalid disk i/o rates 2024-11-24 15:56:12 -05:00
hank
b01fc316c3 New Crowdin updates (#293)
* New translations en.po (Turkish)

* New translations en.po (Russian)

* New translations en.po (Ukrainian)

Co-authored-by: zoixc <113115585+zoixc@users.noreply.github.com>
Co-authored-by: stanol <stanol777@gmail.com>
2024-11-22 13:11:26 -05:00
Henry Dollman
4479249ac7 feat: add --china-mirrors flag when locale is zh-CN in install command 2024-11-19 14:42:56 -05:00
Henry Dollman
0529837ac8 update agent install script arg parsing 2024-11-19 14:31:50 -05:00
Henry Dollman
d51ffa17ed Merge branch 'main' of https://github.com/Alice39s/beszel 2024-11-19 13:45:50 -05:00
al1cE
c434a44bc4 feat: use --china-mirrors instead of -c 2024-11-19 13:30:40 +09:00
al1cE
7b5ac23a4b feat(install): enhance install-agent.sh with China mirror support
- Add `-c` flag to use GitHub mirror (ghp.ci) for mainland China users
- Implement checksum verification for downloaded files
- Add progress bar for file downloads

This change improves installation reliability and user experience,
especially for users in regions (China mainland etc.) with limited GitHub access.
2024-11-19 12:56:35 +09:00
Henry Dollman
87ef769086 Merge branch 'main' of https://github.com/wwng2333/beszel 2024-11-17 11:23:06 -05:00
D
bcefb8e43c fix IPv6 connection problem
use net.JoinHostPort to fix ipv6 address at createSystemConnection
2024-11-17 16:34:05 +08:00
Henry Dollman
a1641c5bcc change ssh related timeouts from 5s to 4s 2024-11-15 16:29:36 -05:00
Henry Dollman
e6839480d9 use sync.Map for system connections 2024-11-15 15:35:26 -05:00
hank
4e64d9efad New Crowdin updates (#285)
* New translations en.po (Dutch)

* New translations en.po (Ukrainian)

* New translations en.po (German)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
Co-authored-by: stanol <stanol777@gmail.com>
Co-authored-by: Henry <henry@obamium.net>
2024-11-14 15:28:38 -05:00
hank
d68f4514cc New Crowdin updates (#282)
* New translations en.po (Chinese Simplified)

Co-authored-by: Sliots <Sliots@hotmail.com>
2024-11-13 12:02:29 -05:00
Henry Dollman
8a69c09939 release 0.8.0 2024-11-12 18:28:25 -05:00
Henry Dollman
e87af81db4 change spinner to visibility hidden after chart loads 2024-11-12 18:25:27 -05:00
Henry Dollman
6043c59da8 update translations 2024-11-12 18:19:37 -05:00
Henry Dollman
4cb7b97416 change podman socket path to use current uid 2024-11-12 18:14:43 -05:00
Henry Dollman
b1db450e00 enable gpu monitoring by default 2024-11-12 18:13:57 -05:00
Henry Dollman
2e8ac98924 Improve disk discovery slightly by checking partition labels 2024-11-12 18:11:44 -05:00
Henry Dollman
529a628368 remove stopped containers from net chart 2024-11-12 17:33:18 -05:00
Henry Dollman
8a2e821c8f style updates 2024-11-12 15:03:15 -05:00
Henry Dollman
3cd11d6bc4 improve podman support (#211) 2024-11-12 11:59:56 -05:00
Henry Dollman
db092d2440 Merge branch 'main' of github.com:henrygd/beszel 2024-11-11 16:56:16 -05:00
Henry Dollman
a4a7c91fc1 dark mode tweaks 2024-11-11 16:56:10 -05:00
hank
543fd44cb2 New Crowdin updates (#273)
* New translations en.po (Chinese Simplified)

Co-authored-by: D <17147265+wwng2333@users.noreply.github.com>
2024-11-11 13:32:58 -05:00
Henry Dollman
eab262c3f7 dark theme updates 2024-11-11 12:55:44 -05:00
Henry Dollman
52bde8ea6d release 0.7.4 2024-11-08 20:32:27 -05:00
Henry Dollman
03de73560c add gpu power consumption chart 2024-11-08 20:31:22 -05:00
Henry Dollman
bcb7de1b9a add Dutch language 2024-11-08 19:02:49 -05:00
hank
ca94bd32f2 New Crowdin updates (#272)
* New translations en.po (Dutch)

* New translations en.po (Dutch)

* New translations en.po (Dutch)

Co-authored-by: Mathy Vandersmissen <mathy.vds@gmail.com>
2024-11-08 18:07:05 -05:00
Henry Dollman
cd10727795 gpu usage and vram charts 2024-11-08 18:00:30 -05:00
Henry Dollman
8262a9a45b progress on gpu metrics 2024-11-08 16:52:50 -05:00
hank
b433437636 New Crowdin updates (#271)
* New translations en.po (Chinese Simplified)

* New translations en.po (Chinese Simplified)

Co-authored-by: D <17147265+wwng2333@users.noreply.github.com>
2024-11-07 15:57:07 -05:00
Henry Dollman
02825ed109 add Polish and Croatian languages 2024-11-07 13:11:58 -05:00
Henry Dollman
a97e6149bb add option for automatic updates to install-agent.sh (#268) 2024-11-07 13:02:01 -05:00
hank
946b1e7f54 New Crowdin updates (#259)
* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Polish)

* New translations en.po (Croatian)

* New translations en.po (Croatian)

* New translations en.po (French)

* New translations en.po (French)

Co-authored-by: Dino Horvat <73183619+DinoHorvat96@users.noreply.github.com>
Co-authored-by: dymek37 <122745160+dymek37@users.noreply.github.com>
Co-authored-by: Adam Gąsowski <gander@users.noreply.github.com>
Co-authored-by: Damien Fajole <60252259+damsdev1@users.noreply.github.com>
2024-11-06 19:06:16 -05:00
Henry Dollman
b5ed7cd555 fix display of values under 1 GB in disk usage chart (closes #261) 2024-11-06 13:52:35 -05:00
Henry Dollman
233349fb2a release 0.7.3 2024-11-04 21:33:19 -05:00
Henry Dollman
c54e6ff0ea po file formatting 2024-11-04 21:33:05 -05:00
Henry Dollman
98c4102f72 revert to previous behavior for displaying stopped containers
* Previously, a stopped container was completely removed from the chart/tooltip during the time period when it was not running. In the last few releases, the container remained in the chart with zero values if it was running at any time during the chart's duration. This restores the previous functionality.
2024-11-04 21:24:44 -05:00
hank
640ee7a88e New translations en.po (Polish) (#257) 2024-11-04 20:57:01 -05:00
Henry Dollman
8a85246a0b set lang in activateLocale func instead of dynamicActivate 2024-11-04 20:56:33 -05:00
Henry Dollman
655bfc95ca add ability to specify partition for extra disk using folder name 2024-11-04 20:52:27 -05:00
hank
37a066e6bd New Crowdin updates (#256)
* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Portuguese)

* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Chinese Traditional, Hong Kong)

* New translations en.po (Italian)
2024-11-04 16:01:01 -05:00
Henry Dollman
9e959a6b7b update translations 2024-11-04 15:42:23 -05:00
Henry Dollman
2b6560b9e1 update translation messages on build
* add lingui extract to build script
* delete translation ts files and add to gitignore
2024-11-04 15:34:59 -05:00
hank
d8836d53bf New Crowdin updates (#252)
* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Portuguese)

* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Chinese Traditional, Hong Kong)

* New translations en.po (Italian)
2024-11-04 14:30:27 -05:00
Henry Dollman
aa15876aa2 fix: read/write labels swapped for extra disk charts (#254) 2024-11-04 14:29:10 -05:00
Henry Dollman
7ca960b521 update system view grid to min xl 2024-11-04 14:18:08 -05:00
Henry Dollman
4eaedcf825 release 0.7.2 2024-11-03 15:31:39 -05:00
Henry Dollman
b337ba1d7f fix subheading for memory chart 2024-11-03 15:30:35 -05:00
hank
c9b72f724f New translations en.po (Ukrainian) (#251)
Co-authored-by: stanol <stanol777@gmail.com>
2024-11-03 15:02:59 -05:00
Henry Dollman
35d8996e00 release 0.7.1 2024-11-03 12:10:53 -05:00
Henry Dollman
6e61c5f1e4 update go deps 2024-11-03 12:10:17 -05:00
Henry Dollman
6bb147c349 fix en fallback if detected user locale is missing (#247) 2024-11-03 11:59:49 -05:00
hank
3668aa4e8e New Crowdin updates (#246)
* New translations en.po (French)

* New translations en.po (Spanish)

* New translations en.po (Arabic)

* New translations en.po (German)

* New translations en.po (Japanese)

* New translations en.po (Korean)

* New translations en.po (Portuguese)

* New translations en.po (Russian)

* New translations en.po (Turkish)

* New translations en.po (Ukrainian)

* New translations en.po (Chinese Simplified)

* New translations en.po (Vietnamese)

* New translations en.po (Chinese Traditional, Hong Kong)

* New translations en.po (Italian)

* New translations en.po (Ukrainian)
2024-11-03 11:28:27 -05:00
Henry Dollman
4c324bff73 release 0.7.0 2024-11-02 14:50:59 -04:00
Henry Dollman
741575df15 revert tweaks for old docker. needs more testing. 2024-11-02 14:43:35 -04:00
170 changed files with 26927 additions and 7944 deletions

61
.github/DISCUSSION_TEMPLATE/support.yml vendored Normal file
View 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
View 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
View 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.

View 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
View File

@@ -0,0 +1 @@
buy_me_a_coffee: henrygd

View File

@@ -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
View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
} }

View 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)
})
}
}

View File

@@ -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()
} }

View File

@@ -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
) )

View File

@@ -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=

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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]
}

View 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
}

View 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)
})
}
}

View File

@@ -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, ",") {

View File

@@ -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
}

View 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)
}
}

View File

@@ -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")

View File

@@ -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})
} }

View 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,
})
}

View 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,
})
}
}

View File

@@ -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

View File

@@ -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})
} }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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"})
} }

View File

@@ -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
})
}

File diff suppressed because it is too large Load Diff

View 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)
})
}

View File

@@ -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)
} }

Binary file not shown.

View File

@@ -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")

View File

@@ -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>

View File

@@ -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: [

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -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

View File

@@ -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

View 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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
})

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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
}, []) }, [])

View File

@@ -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
} }

View 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>
)
})

View File

@@ -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={() => {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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)
} }

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" })
} }
}, []) }, [])

View File

@@ -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"

View File

@@ -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>
</> </>
) )

View File

@@ -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>

View File

@@ -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" />
)} )}

View File

@@ -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>
</>
)
})

View File

@@ -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}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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
)} )}
> >

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}
/> />
) )

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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": "🇭🇰"
}
]

View 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

View File

@@ -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) => {

View File

@@ -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)

File diff suppressed because it is too large Load Diff

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