Compare commits
	
		
			883 Commits
		
	
	
		
			v0.0.1
			...
			battery-fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 3e73399b87 | ||
|   | e149366451 | ||
|   | 8da1ded73e | ||
|   | efa37b2312 | ||
|   | bcdb4c92b5 | ||
|   | a7d07310b6 | ||
|   | 8db87e5497 | ||
|   | e601a0d564 | ||
|   | 07491108cd | ||
|   | 42ab17de1f | ||
|   | 2d14174f61 | ||
|   | a19ccc9263 | ||
|   | 956880aa59 | ||
|   | b2b54db409 | ||
|   | 32d5188eef | ||
|   | 46dab7f531 | ||
|   | c898a9ebbc | ||
|   | 8a13b05c20 | ||
|   | 86ea23fe39 | ||
|   | a284dd74dd | ||
|   | 6a0075291c | ||
|   | f542bc70a1 | ||
|   | 270e59d9ea | ||
|   | 0d97a604f8 | ||
|   | f6078fc232 | ||
|   | 6f5d95031c | ||
|   | 4e26defdca | ||
|   | cda8fa7efd | ||
|   | fd050f2a8f | ||
|   | e53d41dcec | ||
|   | a1eb15dabb | ||
|   | eb4bdafbea | ||
|   | fea2330534 | ||
|   | 5e37469ea9 | ||
|   | e027479bb1 | ||
|   | 1597e869c1 | ||
|   | 862399d8ec | ||
|   | f6f85f8f9d | ||
|   | e22d7ca801 | ||
|   | c382c1d5f6 | ||
|   | f7618ed6b0 | ||
|   | d1295b7c50 | ||
|   | a162a54a58 | ||
|   | 794db0ac6a | ||
|   | e9fb9b856f | ||
|   | 66bca11d36 | ||
|   | 86e87f0d47 | ||
|   | fadfc5d81d | ||
|   | fc39ff1e4d | ||
|   | 82ccfc66e0 | ||
|   | 890bad1c39 | ||
|   | 9c458885f1 | ||
|   | d2aed0dc72 | ||
|   | 3dbcb5d7da | ||
|   | 57a1a8b39e | ||
|   | ab81c04569 | ||
|   | 0c32be3bea | ||
|   | 81d43fbf6e | ||
|   | 96f441de40 | ||
|   | 0e95caaee9 | ||
|   | 7697a12b42 | ||
|   | 94245a9ba4 | ||
|   | b084814aea | ||
|   | cce74246ee | ||
|   | a3420b8c67 | ||
|   | e1bb17ee9e | ||
|   | 52983f60b7 | ||
|   | 1f053fd85d | ||
|   | a989d121d3 | ||
|   | 50d2406423 | ||
|   | 059d2d0a5b | ||
|   | 621bef30b5 | ||
|   | 5f4d3dc730 | ||
|   | 8fa9aece63 | ||
|   | 2f1a022e2a | ||
|   | 4815cd29bc | ||
|   | e49bfaf5d7 | ||
|   | b13915b76f | ||
|   | e2a57dc43b | ||
|   | 7222224b40 | ||
|   | 02ff475b84 | ||
|   | 09cd8d0db9 | ||
|   | 36f1a0c53b | ||
|   | 0b0e94e045 | ||
|   | 20ca6edf81 | ||
|   | 1990f8c6df | ||
|   | 6e9dbf863f | ||
|   | fa921d77f1 | ||
|   | ff854d481d | ||
|   | 4ce491fe48 | ||
|   | 493bae7eb6 | ||
|   | ae5532aa36 | ||
|   | a1eae6413a | ||
|   | ee52bf1fbf | ||
|   | 2ff0bd6b44 | ||
|   | a385233b7d | ||
|   | f5648a415d | ||
|   | 556fb18953 | ||
|   | a482f78739 | ||
|   | 4a580ce972 | ||
|   | e07558237f | ||
|   | fb3c70a1bc | ||
|   | cba4d60895 | ||
|   | 8b655ef2b9 | ||
|   | 0188418055 | ||
|   | 72334c42d0 | ||
|   | 0638ff3c21 | ||
|   | b64318d9e8 | ||
|   | 0f5b1b5157 | ||
|   | 3c4ae46f50 | ||
|   | c158b1aeeb | ||
|   | 684d92c497 | ||
|   | bbd9595ec0 | ||
|   | bbebb3e301 | ||
|   | 9d25181d1d | ||
|   | 7ba1f366ba | ||
|   | 37c6b920f9 | ||
|   | 49db81dac8 | ||
|   | a9e90ec19c | ||
|   | 2ad60507b7 | ||
|   | 12059ee3db | ||
|   | de56544ca3 | ||
|   | 065c7facb6 | ||
|   | 630c92c139 | ||
|   | e11d452d91 | ||
|   | 99c7f7bd8a | ||
|   | 8af3a0eb5b | ||
|   | 5f7950b474 | ||
|   | df9e2dec28 | ||
|   | a0f271545a | ||
|   | aa2bc9f118 | ||
|   | b22ae87022 | ||
|   | 79e79079bc | ||
|   | 1811ebdee4 | ||
|   | 137f3f3e24 | ||
|   | ed1d1e77c0 | ||
|   | 8c36dd1caa | ||
|   | 57bfe72486 | ||
|   | 75f66b0246 | ||
|   | ce93d54aa7 | ||
|   | 39dbe0eac5 | ||
|   | 7282044f80 | ||
|   | d77c37c0b0 | ||
|   | e362cbbca5 | ||
|   | 118544926b | ||
|   | d4bb0a0a30 | ||
|   | fe5e35d1a9 | ||
|   | 60a6ae2caa | ||
|   | 80338d36aa | ||
|   | f0d2c242e8 | ||
|   | 559f83d99c | ||
|   | d3a751ee6c | ||
|   | fb70a166fa | ||
|   | c12457b707 | ||
|   | 3e53d73d56 | ||
|   | 80338c5e98 | ||
|   | 249cd8ad19 | ||
|   | ccdff46370 | ||
|   | 91679b5cc0 | ||
|   | 6953edf59e | ||
|   | b91c77ec92 | ||
|   | 3ac0b185d1 | ||
|   | 1e675cabb5 | ||
|   | 5f44965c2c | ||
|   | f080929296 | ||
|   | f055658eba | ||
|   | e430c747fe | ||
|   | ca62b1db36 | ||
|   | 38569b7057 | ||
|   | 203244090f | ||
|   | 2bed722045 | ||
|   | 13f3a52760 | ||
|   | 16b9827c70 | ||
|   | 0fc352d7fc | ||
|   | 8a2bee11d4 | ||
|   | 485f7d16ff | ||
|   | 46fdc94cb8 | ||
|   | 261f7fb76c | ||
|   | 18d9258907 | ||
|   | 9d7fb8ab80 | ||
|   | 3730a78e5a | ||
|   | 7cdd0907e8 | ||
|   | 3586f73f30 | ||
|   | 752ccc6beb | ||
|   | f577476c81 | ||
|   | 49ae424698 | ||
|   | d4fd19522b | ||
|   | 5c047e4afd | ||
|   | 6576141f54 | ||
|   | 926e807020 | ||
|   | d91847c6c5 | ||
|   | 0abd88270c | ||
|   | 806c4e51c5 | ||
|   | 6520783fe9 | ||
|   | 48c8a3a4a5 | ||
|   | e0c839f78c | ||
|   | 1ba362bafe | ||
|   | b5d55ead4a | ||
|   | 4f879ccc66 | ||
|   | cd9e0f7b5b | ||
|   | 780644eeae | ||
|   | 71f081da20 | ||
|   | 11c61bcf42 | ||
|   | 402a1584d7 | ||
|   | 99d61a0193 | ||
|   | 5ddb200a75 | ||
|   | faa247dbda | ||
|   | 6d1cec3c42 | ||
|   | 529df84273 | ||
|   | e0e21eedd6 | ||
|   | 4356ffbe9b | ||
|   | be1366b785 | ||
|   | 3dc7e02ed0 | ||
|   | d67d638a6b | ||
|   | 7b36036455 | ||
|   | 1b58560acf | ||
|   | 1627c41f84 | ||
|   | 4395520a28 | ||
|   | 8c52f30a71 | ||
|   | 46316ebffa | ||
|   | 0b04f60b6c | ||
|   | 20b822d072 | ||
|   | ca7642cc91 | ||
|   | 68009c85a5 | ||
|   | 1c7c64c4aa | ||
|   | b05966d30b | ||
|   | ea90f6a596 | ||
|   | f1e43b2593 | ||
|   | 748d18321d | ||
|   | ae84919c39 | ||
|   | b23221702e | ||
|   | 4d5b096230 | ||
|   | 7caf7d1b31 | ||
|   | 6107f52d07 | ||
|   | f4fb7a89e5 | ||
|   | 5439066f4d | ||
|   | 7c18f3d8b4 | ||
|   | 63af81666b | ||
|   | c0a6153a43 | ||
|   | df334caca6 | ||
|   | ffb3ec0477 | ||
|   | 3a97edd0d5 | ||
|   | ab1d1c1273 | ||
|   | 0fb39edae4 | ||
|   | 3a977a8e1f | ||
|   | 081979de24 | ||
|   | 23fe189797 | ||
|   | e9d429b9b8 | ||
|   | 99202c85b6 | ||
|   | d5c3d8f84e | ||
|   | 8f442992e6 | ||
|   | 39820c8ac1 | ||
|   | 0c8b10af99 | ||
|   | 8e072492b7 | ||
|   | 88d6307ce0 | ||
|   | 2cc516f9e5 | ||
|   | ab6ea71695 | ||
|   | 6280323cb1 | ||
|   | 17c8e7e1bd | ||
|   | f60fb6f8a9 | ||
|   | 3eebbce2d4 | ||
|   | e92a94a24d | ||
|   | 7c7c073ae4 | ||
|   | c009a40749 | ||
|   | 5e85b803e0 | ||
|   | 256d3c5ba1 | ||
|   | bd048a8989 | ||
|   | f6b4231500 | ||
|   | bda06f30b3 | ||
|   | 38f2ba3984 | ||
|   | 1a7d897bdc | ||
|   | c74e7430ef | ||
|   | 2467bbc0f0 | ||
|   | ea665e02da | ||
|   | 358e05d544 | ||
|   | aab5725d82 | ||
|   | e94a1cd421 | ||
|   | 73c1a1b208 | ||
|   | 0526c88ce0 | ||
|   | a2e9056a00 | ||
|   | fd4ac60908 | ||
|   | 330e4c67f3 | ||
|   | 5d840bd473 | ||
|   | 54e3f3eba1 | ||
|   | d79111fce4 | ||
|   | 93c3c7b9d8 | ||
|   | 410d236f89 | ||
|   | 9a8071c314 | ||
|   | 80df0efccd | ||
|   | 3f1f4c7596 | ||
|   | 04ac688be4 | ||
|   | ace83172ff | ||
|   | e8b864b515 | ||
|   | 7057f2e917 | ||
|   | 47b2689f24 | ||
|   | 9b65110aef | ||
|   | 3935a9bf00 | ||
|   | fb2adf08dc | ||
|   | 61441b115b | ||
|   | 3ad78a2588 | ||
|   | 81514d4deb | ||
|   | faeb801512 | ||
|   | 968ca70670 | ||
|   | 5837b4f25c | ||
|   | c38d04b34b | ||
|   | cadc09b493 | ||
|   | edefc6f53e | ||
|   | 400ea89587 | ||
|   | 3058c24e82 | ||
|   | 521be05bc1 | ||
|   | 6b766b2653 | ||
|   | d36b8369cc | ||
|   | ae22334645 | ||
|   | 1d7c0ebc27 | ||
|   | 3b9910351d | ||
|   | f397ab0797 | ||
|   | b1fc715ec9 | ||
|   | d25c7c58c1 | ||
|   | a6daa70010 | ||
|   | d722e4712c | ||
|   | 1d61ad5d7c | ||
|   | 28589455bf | ||
|   | dd21c18939 | ||
|   | fd79bc3341 | ||
|   | 7edcf8db85 | ||
|   | 245a047062 | ||
|   | 520b52e532 | ||
|   | c421ffac70 | ||
|   | 6767392ea8 | ||
|   | 25b73bfb85 | ||
|   | 5fbc0de07f | ||
|   | c8130a10d4 | ||
|   | 0619eabec2 | ||
|   | 5b4d5c648e | ||
|   | 0443a85015 | ||
|   | c4d8deb986 | ||
|   | 681286eb4f | ||
|   | 99cdb196ca | ||
|   | 31431fd211 | ||
|   | 9e56f4611f | ||
|   | a1f6eeb9eb | ||
|   | f8a1d9fc5d | ||
|   | d81db6e319 | ||
|   | 17a163de26 | ||
|   | 85db31a8cd | ||
|   | 327db38953 | ||
|   | 0413368762 | ||
|   | db73928604 | ||
|   | add1b27346 | ||
|   | 2ef1fe6b2a | ||
|   | 2b43ba3cbe | ||
|   | b2b1a0b6ea | ||
|   | b11d0aae61 | ||
|   | 2b73d8845a | ||
|   | 41e3e3d760 | ||
|   | c22b57ce67 | ||
|   | 23bee0aa7c | ||
|   | 0c2629f57e | ||
|   | a4b689e8f1 | ||
|   | 0c5841133b | ||
|   | 78a645fa05 | ||
|   | f1208a9f00 | ||
|   | aeb5f1424b | ||
|   | 4a1fb513c5 | ||
|   | e5a66cc156 | ||
|   | aef3b3e610 | ||
|   | 1d13ecd8ec | ||
|   | 6404895a47 | ||
|   | ba7db28e80 | ||
|   | 6b41a98338 | ||
|   | b958e9eefe | ||
|   | baf56fe83b | ||
|   | 96f9128d1a | ||
|   | 1fb60e05d7 | ||
|   | a9a9a932a6 | ||
|   | e0a1a49a8f | ||
|   | 79eb42d04d | ||
|   | 25b70af196 | ||
|   | c12b27afb5 | ||
|   | 7485f79071 | ||
|   | d170e7a00d | ||
|   | 1a6a2a64f2 | ||
|   | 646b899851 | ||
|   | 821e2e3a78 | ||
|   | 9be3fcb8ca | ||
|   | f271b5a56c | ||
|   | 4f80a58929 | ||
|   | 2ab2cc83de | ||
|   | 3376a97bea | ||
|   | 0c54f95546 | ||
|   | 5ea6eb08a1 | ||
|   | 6b2a9463ca | ||
|   | a94cfff965 | ||
|   | 1f69937572 | ||
|   | aa3de511b9 | ||
|   | 3afab00937 | ||
|   | e6054058b9 | ||
|   | 31d52d5e15 | ||
|   | 44d930a700 | ||
|   | d7ada1b1c5 | ||
|   | 1daf35406a | ||
|   | 2216e40f04 | ||
|   | f4480c7aa7 | ||
|   | 5b478c11eb | ||
|   | 58085bf300 | ||
|   | ce171cf375 | ||
|   | e689f547ef | ||
|   | 5a8e8c1512 | ||
|   | ff5eb07716 | ||
|   | fdbbbc77b0 | ||
|   | 20cba1b695 | ||
|   | 207d58a07e | ||
|   | 0759a3607c | ||
|   | 0b4742d064 | ||
|   | 4557f18195 | ||
|   | 83668e5727 | ||
|   | 120aff0d18 | ||
|   | 7170b24160 | ||
|   | 3441b39a02 | ||
|   | 31d306f8be | ||
|   | 76347f25e5 | ||
|   | c157f38957 | ||
|   | d185dfdef8 | ||
|   | 319a9895b0 | ||
|   | 68dae3967d | ||
|   | 0a331524cc | ||
|   | 5b625db57c | ||
|   | 0943e01b71 | ||
|   | cfda7d0740 | ||
|   | 4e3d198b7b | ||
|   | a6a9719565 | ||
|   | ddb4f1c8f8 | ||
|   | 55d13c551a | ||
|   | fef30b1750 | ||
|   | 0cee9e4e4b | ||
|   | 90378d09a3 | ||
|   | 7adf7ef549 | ||
|   | ee6a456b66 | ||
|   | 4789f48ad0 | ||
|   | ea098fd61c | ||
|   | e7c214799a | ||
|   | 9cabc103e5 | ||
|   | be955e0122 | ||
|   | 8a0f2d61a8 | ||
|   | 48ed4abc02 | ||
|   | e8c680bda7 | ||
|   | 37c7a32c10 | ||
|   | 1c4533f1f2 | ||
|   | 5d88599c9a | ||
|   | 73427306d1 | ||
|   | f24a7313d6 | ||
|   | ad55d1ca88 | ||
|   | 9c669d8833 | ||
|   | 81fa4f16d6 | ||
|   | 40cc1a875e | ||
|   | 1ac165d7d3 | ||
|   | 9619e6cf89 | ||
|   | fc31cefd4c | ||
|   | 5fd9010b39 | ||
|   | c2e3dd5ab1 | ||
|   | 8e531e6b3c | ||
|   | 527e6b57d5 | ||
|   | 245fa538e9 | ||
|   | e14a851398 | ||
|   | 0c9bc47a3a | ||
|   | 19b4477a75 | ||
|   | 558d051c42 | ||
|   | 9c8528bae1 | ||
|   | 229ef19376 | ||
|   | e5fb4d611a | ||
|   | bc9dc9704c | ||
|   | e88eb1a884 | ||
|   | d8f3206e8b | ||
|   | 729d306157 | ||
|   | c35df48754 | ||
|   | 0f97f37a79 | ||
|   | b08219dacf | ||
|   | dd10fb97c0 | ||
|   | 87354df2de | ||
|   | 1bd04498b9 | ||
|   | 52394bc99b | ||
|   | add85e9747 | ||
|   | e82986adff | ||
|   | f201267e4e | ||
|   | 9db41f8830 | ||
|   | ba64c59632 | ||
|   | d2626d8337 | ||
|   | ded1090190 | ||
|   | 1114baaaa0 | ||
|   | cf13c1c671 | ||
|   | e70de6a59e | ||
|   | 5110eaf10f | ||
|   | 0234682720 | ||
|   | 80a7322fa1 | ||
|   | 59bdc0ce0d | ||
|   | a288d0925b | ||
|   | e7d2f0d82b | ||
|   | 825d8269ff | ||
|   | f7775d173a | ||
|   | 58bced5f09 | ||
|   | 6e08507dde | ||
|   | 617a03fc15 | ||
|   | f86bda304d | ||
|   | 1d414e659b | ||
|   | 87f7390eca | ||
|   | ed01752546 | ||
|   | 46002a2171 | ||
|   | 14716d36a6 | ||
|   | b4bc8a31aa | ||
|   | b01fc316c3 | ||
|   | 4479249ac7 | ||
|   | 0529837ac8 | ||
|   | d51ffa17ed | ||
|   | c434a44bc4 | ||
|   | 7b5ac23a4b | ||
|   | 87ef769086 | ||
|   | bcefb8e43c | ||
|   | a1641c5bcc | ||
|   | e6839480d9 | ||
|   | 4e64d9efad | ||
|   | d68f4514cc | ||
|   | 8a69c09939 | ||
|   | e87af81db4 | ||
|   | 6043c59da8 | ||
|   | 4cb7b97416 | ||
|   | b1db450e00 | ||
|   | 2e8ac98924 | ||
|   | 529a628368 | ||
|   | 8a2e821c8f | ||
|   | 3cd11d6bc4 | ||
|   | db092d2440 | ||
|   | a4a7c91fc1 | ||
|   | 543fd44cb2 | ||
|   | eab262c3f7 | ||
|   | 52bde8ea6d | ||
|   | 03de73560c | ||
|   | bcb7de1b9a | ||
|   | ca94bd32f2 | ||
|   | cd10727795 | ||
|   | 8262a9a45b | ||
|   | b433437636 | ||
|   | 02825ed109 | ||
|   | a97e6149bb | ||
|   | 946b1e7f54 | ||
|   | b5ed7cd555 | ||
|   | 233349fb2a | ||
|   | c54e6ff0ea | ||
|   | 98c4102f72 | ||
|   | 640ee7a88e | ||
|   | 8a85246a0b | ||
|   | 655bfc95ca | ||
|   | 37a066e6bd | ||
|   | 9e959a6b7b | ||
|   | 2b6560b9e1 | ||
|   | d8836d53bf | ||
|   | aa15876aa2 | ||
|   | 7ca960b521 | ||
|   | 4eaedcf825 | ||
|   | b337ba1d7f | ||
|   | c9b72f724f | ||
|   | 35d8996e00 | ||
|   | 6e61c5f1e4 | ||
|   | 6bb147c349 | ||
|   | 3668aa4e8e | ||
|   | 4c324bff73 | ||
|   | 741575df15 | ||
|   | 055fc39305 | ||
|   | 5ae3a38204 | ||
|   | 44747e75b0 | ||
|   | e4f22ebb01 | ||
|   | bfb848a1ec | ||
|   | c16c7830a4 | ||
|   | 8f383c9f5e | ||
|   | 5b68556a9a | ||
|   | cb1c481f54 | ||
|   | a93ff63605 | ||
|   | 856683610a | ||
|   | b9fda9dd0b | ||
|   | 7e27fee006 | ||
|   | f65d19ad84 | ||
|   | 94f771fc1c | ||
|   | 0ac3d20162 | ||
|   | df0f3a154f | ||
|   | 6419178d87 | ||
|   | 91714ba0e6 | ||
|   | b5ba5054a5 | ||
|   | 6f38077ca0 | ||
|   | 7f82aafff9 | ||
|   | 14a4715eb8 | ||
|   | e4f1936698 | ||
|   | 4f62a07da6 | ||
|   | 1a1fcebc46 | ||
|   | f9f7db17d4 | ||
|   | 929d94f705 | ||
|   | 2c4ea6f52a | ||
|   | 3505b215a2 | ||
|   | 8827996553 | ||
|   | 556a6b49db | ||
|   | 180ec83a17 | ||
|   | 062796b38c | ||
|   | 67f88188e1 | ||
|   | 3209c53201 | ||
|   | ec7aa80928 | ||
|   | f6e391f8a9 | ||
|   | e64fad9584 | ||
|   | 9e6ee8d239 | ||
|   | 2c66f93101 | ||
|   | 5c2e2d7d36 | ||
|   | 376e8d4621 | ||
|   | ec7cb53d93 | ||
|   | b7176fc8f3 | ||
|   | f8fc74116c | ||
|   | 4094df3a61 | ||
|   | a5f9e2615c | ||
|   | 4a78ce1b16 | ||
|   | f8f1e01cb4 | ||
|   | c7463f2b9f | ||
|   | a975466fc7 | ||
|   | 539c0ccb1d | ||
|   | 5f4dcb09ea | ||
|   | 6de5dce176 | ||
|   | b5c158d1b3 | ||
|   | 7f01d1ec7e | ||
|   | 8bf7a0e1d6 | ||
|   | 140fd93ec9 | ||
|   | bdcb34c989 | ||
|   | aaaa86b147 | ||
|   | 6e9b84c6c7 | ||
|   | cce241caa4 | ||
|   | 1e9787c4d7 | ||
|   | 71aa9946f5 | ||
|   | 12239808fc | ||
|   | 94e9d4f270 | ||
|   | 34a8053967 | ||
|   | ee92e338cb | ||
|   | 1a3ad04e03 | ||
|   | 9c061774a3 | ||
|   | 3336b0a7d9 | ||
|   | f034eed431 | ||
|   | 6b6d3fabc0 | ||
|   | 59d541dd1d | ||
|   | abff85d61e | ||
|   | 02641ec007 | ||
|   | 92179cbbb2 | ||
|   | 299152413a | ||
|   | 703a3c41c9 | ||
|   | 31d1153916 | ||
|   | c1577d3ba5 | ||
|   | c4400eb0a3 | ||
|   | a57498f8f7 | ||
|   | 1b0dffc1ab | ||
|   | bea37d62b4 | ||
|   | d53b6be5b9 | ||
|   | 6c31263e60 | ||
|   | b464fa5b3f | ||
|   | c0a3bbeefc | ||
|   | 10d348c052 | ||
|   | 6cf6661f2e | ||
|   | 23ab1208cd | ||
|   | 5b0fac429b | ||
|   | efca56ceca | ||
|   | 64f0a23969 | ||
|   | 4245da7792 | ||
|   | cedf80a869 | ||
|   | 76cea9d3c3 | ||
|   | 10ef430826 | ||
|   | d672017af0 | ||
|   | 7a82571921 | ||
|   | e81f8ac387 | ||
|   | 05faa88e6a | ||
|   | 73aae62c2e | ||
|   | af4877ca30 | ||
|   | c407fe9af0 | ||
|   | 13c9497951 | ||
|   | 4274096645 | ||
|   | a213b70a1c | ||
|   | 66cc0a4b24 | ||
|   | f051f6a5f8 | ||
|   | b9f142c28c | ||
|   | 45e1283b83 | ||
|   | 94cb5f2798 | ||
|   | 2883467b2b | ||
|   | 0c77190161 | ||
|   | 8d4d072343 | ||
|   | d6e0daf52a | ||
|   | 22e9ede766 | ||
|   | 9ab359d3cf | ||
|   | 5447ccad47 | ||
|   | 3e51d79c37 | ||
|   | 0996d60224 | ||
|   | 7a5ec067f5 | ||
|   | 98563d643d | ||
|   | 268e364bd4 | ||
|   | dd84a9fd35 | ||
|   | 2f4e537f72 | ||
|   | 9637363cf3 | ||
|   | 73d0dd25ec | ||
|   | 2ecf5572ba | ||
|   | 5e97167ee0 | ||
|   | 1a4862ecd9 | ||
|   | 6235d15fa2 | ||
|   | 4694642674 | ||
|   | 56c0b86025 | ||
|   | 82e3f3c7c1 | ||
|   | 38a9c535b8 | ||
|   | 34c83e7c17 | ||
|   | fe5732d75a | ||
|   | cc32b50d82 | ||
|   | 764e043e83 | ||
|   | cec9339f6d | ||
|   | f96f04f876 | ||
|   | 06b1c2200b | ||
|   | e88e2bf3dc | ||
|   | 8621a45383 | ||
|   | f2ddee9216 | ||
|   | f350d61ee2 | ||
|   | 2d670c585d | ||
|   | 55d1c00903 | ||
|   | 78a9086b55 | ||
|   | 4ee169fea5 | ||
|   | a286bed54c | ||
|   | 314cee081a | ||
|   | e287124632 | ||
|   | 9cccefd3fa | ||
|   | ec95f63806 | ||
|   | 812fe20df7 | ||
|   | ddfcbc546b | ||
|   | c74d5496af | ||
|   | 060846d70a | ||
|   | e03e2b8d67 | ||
|   | c46879694d | ||
|   | 61a68e5be1 | ||
|   | bd43a2a2c2 | ||
|   | 3aeca6af2f | ||
|   | 3e95269a7c | ||
|   | 53b02dd55f | ||
|   | 43ba9d5c6a | ||
|   | 1cb4a711c3 | ||
|   | aef99c3bd9 | ||
|   | 138cbc13d6 | ||
|   | 62d5ae8236 | ||
|   | 8ce605d65e | ||
|   | c8743201a2 | ||
|   | f16e22e521 | ||
|   | 9710d0d2f1 | ||
|   | 2889d151ea | ||
|   | ce6e887d1b | ||
|   | b4cf5bb1c0 | ||
|   | 9bc7773607 | ||
|   | 3362a3d1cf | ||
|   | 3b13fadde2 | ||
|   | 99d79f7d2d | ||
|   | 1fb23ff673 | ||
|   | 29529d1a84 | ||
|   | 9f84629b92 | ||
|   | d2284c3fed | ||
|   | eb420bef3a | ||
|   | 9cf6c167b0 | ||
|   | fbc7f79660 | ||
|   | 37170f2bdb | ||
|   | af4c05e692 | ||
|   | 202a506485 | ||
|   | aa3866c8ed | ||
|   | f9c0d0b89d | ||
|   | ec5b1a833d | ||
|   | 1cfda8fb9f | ||
|   | 2168db6ebd | ||
|   | e64ef49e97 | ||
|   | 54e0240dd8 | ||
|   | 05f52ad15a | ||
|   | 8ffb3a0cc8 | ||
|   | 953d7cac1e | ||
|   | 1cfd3cdd30 | ||
|   | b4a3cb9ce6 | ||
|   | 7a6fbc8346 | ||
|   | 76cffb16de | ||
|   | 13f7d016e6 | ||
|   | 7a8dccfc97 | ||
|   | 68824935e9 | ||
|   | 42e2e3463e | ||
|   | 2ef54bae36 | ||
|   | 6766a8120a | ||
|   | 9fcf1e7164 | ||
|   | 130c9bd696 | ||
|   | 88db920ebe | ||
|   | 5278805d79 | ||
|   | 55863f849c | ||
|   | 0590facc89 | ||
|   | 3ff339ba1d | ||
|   | e92a1d8a4d | ||
|   | 2590f5612c | ||
|   | 112685bdf4 | ||
|   | 7e1455161d | ||
|   | be98b77634 | ||
|   | cb75923f9f | ||
|   | 87ab4961fd | ||
|   | d053f16058 | ||
|   | 19272c05bf | ||
|   | e56c5f30e0 | ||
|   | 7cf7b706c1 | ||
|   | 58fe9f723a | ||
|   | a337ece52e | ||
|   | 61723a24e0 | ||
|   | b7934931cf | ||
|   | 0566433aa1 | ||
|   | b5607025f7 | ||
|   | 683dc74cbf | ||
|   | c7e67a9b63 | ||
|   | 083da9598e | ||
|   | f8d2161489 | ||
|   | d0d4e546d9 | ||
|   | 2ac5d797d2 | ||
|   | 4105567051 | ||
|   | c06eabefe0 | ||
|   | 9d2192f323 | ||
|   | dd55e74ec9 | ||
|   | 9da1e5751a | ||
|   | ea71492d13 | ||
|   | b51770a703 | ||
|   | 39596b8da8 | ||
|   | 932d126117 | ||
|   | 034a5c21eb | ||
|   | d840178cc0 | ||
|   | 8a04a9bed6 | ||
|   | 3f692ce528 | ||
|   | 876fb6e02e | ||
|   | 2eb691661c | ||
|   | f4332d69d5 | ||
|   | b958e84572 | ||
|   | 7ce6f76315 | ||
|   | cdd10a3011 | ||
|   | dcdee1d943 | ||
|   | f4e82ecd59 | ||
|   | b8a2d0f32f | ||
|   | f13f0b2f8a | ||
|   | fdf0ce22dc | ||
|   | f36b0a4528 | ||
|   | a73a01fe37 | ||
|   | c6b9f1ab77 | ||
|   | 8ef30e0733 | ||
|   | e3ed07a999 | ||
|   | b05184a654 | ||
|   | 2a3b228668 | ||
|   | c3e3d483b0 | ||
|   | b0c6151664 | ||
|   | c9196def32 | ||
|   | 59cbaf3009 | ||
|   | bc3f7257c0 | ||
|   | 4ae65f061c | ||
|   | 0f9aa11255 | ||
|   | 092f09b084 | ||
|   | 4841b95a8d | ||
|   | 0ab9ba0614 | ||
|   | d809704ab3 | ||
|   | 8d71e95d0b | ||
|   | e204bcf9ce | ||
|   | e26e9fce03 | ||
|   | 4dd201de0d | ||
|   | de7e07963d | ||
|   | ac6f50c40c | ||
|   | 4c680a2ab9 | ||
|   | c2cfe8cad6 | ||
|   | 9a43ee8f1d | ||
|   | 556434f043 | ||
|   | f2ff27aaa2 | ||
|   | 93dce463d9 | ||
|   | bb23673547 | ||
|   | 517f949a30 | ||
|   | f54faa6bd6 | ||
|   | c4e62bd099 | ||
|   | d3033ed72e | ||
|   | d0f51e5ca9 | ||
|   | 935dca8679 | ||
|   | 184445f089 | ||
|   | 3dafb8ddd5 | ||
|   | 463681e145 | ||
|   | 26b307a629 | ||
|   | fe82632804 | ||
|   | 94697658f2 | ||
|   | 9a1bfdd24b | ||
|   | b668da17f6 | ||
|   | 26dbb1968a | ||
|   | ee57e84cb8 | 
							
								
								
									
										48
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,48 @@ | ||||
| # Node.js dependencies | ||||
| node_modules | ||||
| internalsite/node_modules | ||||
|  | ||||
| # Go build artifacts and binaries | ||||
| build | ||||
| dist | ||||
| *.exe | ||||
| beszel-agent | ||||
| beszel_data* | ||||
| pb_data | ||||
| data | ||||
| temp | ||||
|  | ||||
| # Development and IDE files | ||||
| .vscode | ||||
| .idea* | ||||
| *.swc | ||||
| __debug_* | ||||
|  | ||||
| # Git and version control | ||||
| .git | ||||
| .gitignore | ||||
|  | ||||
| # Documentation and supplemental files | ||||
| *.md | ||||
| supplemental | ||||
| freebsd-port | ||||
|  | ||||
| # Test files (exclude from production builds) | ||||
| *_test.go | ||||
| coverage | ||||
|  | ||||
| # Docker files | ||||
| dockerfile_* | ||||
|  | ||||
| # Temporary files | ||||
| *.tmp | ||||
| *.bak | ||||
| *.log | ||||
|  | ||||
| # OS specific files | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| # .NET build artifacts | ||||
| agent/lhm/obj | ||||
| agent/lhm/bin | ||||
							
								
								
									
										61
									
								
								.github/DISCUSSION_TEMPLATE/support.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,61 @@ | ||||
| body: | ||||
|   - type: markdown | ||||
|     attributes: | ||||
|       value: | | ||||
|         ### Before opening a discussion: | ||||
|  | ||||
|         - Check the [common issues guide](https://beszel.dev/guide/common-issues). | ||||
|         - Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). | ||||
|  | ||||
|   - type: textarea | ||||
|     id: description | ||||
|     attributes: | ||||
|       label: Description | ||||
|       description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem. | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     id: system | ||||
|     attributes: | ||||
|       label: OS / Architecture | ||||
|       placeholder: linux/amd64 (agent), freebsd/arm64 (hub) | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     id: version | ||||
|     attributes: | ||||
|       label: Beszel version | ||||
|       placeholder: 0.9.1 | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: dropdown | ||||
|     id: install-method | ||||
|     attributes: | ||||
|       label: Installation method | ||||
|       options: | ||||
|         - Docker | ||||
|         - Binary | ||||
|         - Nix | ||||
|         - Unraid | ||||
|         - Coolify | ||||
|         - Other (please describe above) | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     id: config | ||||
|     attributes: | ||||
|       label: Configuration | ||||
|       description: Please provide any relevant service configuration | ||||
|       render: yaml | ||||
|   - type: textarea | ||||
|     id: hub-logs | ||||
|     attributes: | ||||
|       label: Hub Logs | ||||
|       description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). | ||||
|       render: json | ||||
|   - type: textarea | ||||
|     id: agent-logs | ||||
|     attributes: | ||||
|       label: Agent Logs | ||||
|       description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info. | ||||
|       render: shell | ||||
							
								
								
									
										134
									
								
								.github/ISSUE_TEMPLATE/bug_report.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,134 @@ | ||||
| name: 🐛 Bug report | ||||
| description: Report a new bug or issue. | ||||
| title: '[Bug]: ' | ||||
| labels: ['bug', "needs confirmation"] | ||||
| body: | ||||
|   - type: dropdown | ||||
|     id: component | ||||
|     attributes: | ||||
|       label: Component | ||||
|       description: Which part of Beszel is this about? | ||||
|       options: | ||||
|         - Hub | ||||
|         - Agent | ||||
|         - Hub & Agent | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: markdown | ||||
|     attributes: | ||||
|       value: | | ||||
|         ### Thanks for taking the time to fill out this bug report! | ||||
|  | ||||
|         - For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support). | ||||
|         - To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml). | ||||
|         - Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future. | ||||
|  | ||||
|         ### Before submitting a bug report: | ||||
|  | ||||
|         - Check the [common issues guide](https://beszel.dev/guide/common-issues). | ||||
|         - Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). | ||||
|   - type: textarea | ||||
|     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: dropdown | ||||
|     id: category | ||||
|     attributes: | ||||
|       label: Category | ||||
|       description: Which category does this relate to most? | ||||
|       options: | ||||
|         - Metrics | ||||
|         - Charts & Visualization | ||||
|         - Settings & Configuration | ||||
|         - Notifications & Alerts | ||||
|         - Authentication | ||||
|         - Installation | ||||
|         - Performance | ||||
|         - UI / UX | ||||
|         - Other | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: dropdown | ||||
|     id: metrics | ||||
|     attributes: | ||||
|       label: Affected Metrics | ||||
|       description: If applicable, which specific metric does this relate to most? | ||||
|       options: | ||||
|         - CPU | ||||
|         - Memory | ||||
|         - Storage | ||||
|         - Network | ||||
|         - Containers | ||||
|         - GPU | ||||
|         - Sensors | ||||
|         - Other | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: input | ||||
|     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 | ||||
							
								
								
									
										8
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,8 @@ | ||||
| blank_issues_enabled: false | ||||
| contact_links: | ||||
|   - name: 💬 Support and questions | ||||
|     url: https://github.com/henrygd/beszel/discussions | ||||
|     about: Ask and answer questions here. | ||||
|   - name: ℹ️ View the Common Issues page | ||||
|     url: https://beszel.dev/guide/common-issues | ||||
|     about: Find information about commonly encountered problems. | ||||
							
								
								
									
										76
									
								
								.github/ISSUE_TEMPLATE/feature_request.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,76 @@ | ||||
| name: 🚀 Feature request | ||||
| description: Request a new feature or change. | ||||
| title: "[Feature]: " | ||||
| labels: ["enhancement", "needs review"] | ||||
| body: | ||||
|   - type: dropdown | ||||
|     id: component | ||||
|     attributes: | ||||
|       label: Component | ||||
|       description: Which part of Beszel is this about? | ||||
|       options: | ||||
|         - Hub | ||||
|         - Agent | ||||
|         - Hub & Agent | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: markdown | ||||
|     attributes: | ||||
|       value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed). | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: Describe the feature you would like to see | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     id: motivation | ||||
|     attributes: | ||||
|       label: Motivation / Use Case | ||||
|       description: Why do you want this feature? What problem does it solve? | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     attributes: | ||||
|       label: Describe how you would like to see this feature implemented | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: textarea | ||||
|     id: logs | ||||
|     attributes: | ||||
|       label: Screenshots | ||||
|       description: Please attach any relevant screenshots, such as images from your current solution or similar implementations. | ||||
|     validations: | ||||
|       required: false | ||||
|   - type: dropdown | ||||
|     id: category | ||||
|     attributes: | ||||
|       label: Category | ||||
|       description: Which category does this relate to most? | ||||
|       options: | ||||
|         - Metrics | ||||
|         - Charts & Visualization | ||||
|         - Settings & Configuration | ||||
|         - Notifications & Alerts | ||||
|         - Authentication | ||||
|         - Installation | ||||
|         - Performance | ||||
|         - UI / UX | ||||
|         - Other | ||||
|     validations: | ||||
|       required: true | ||||
|   - type: dropdown | ||||
|     id: metrics | ||||
|     attributes: | ||||
|       label: Affected Metrics | ||||
|       description: If applicable, which specific metric does this relate to most? | ||||
|       options: | ||||
|         - CPU | ||||
|         - Memory | ||||
|         - Storage | ||||
|         - Network | ||||
|         - Containers | ||||
|         - GPU | ||||
|         - Sensors | ||||
|         - Other | ||||
|     validations: | ||||
|       required: true | ||||
							
								
								
									
										1
									
								
								.github/funding.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| buy_me_a_coffee: henrygd | ||||
							
								
								
									
										33
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | ||||
| ## 📃 Description | ||||
|  | ||||
| A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need. | ||||
|  | ||||
| ## 📖 Documentation | ||||
|  | ||||
| Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes. | ||||
|  | ||||
| ## 🪵 Changelog | ||||
|  | ||||
| ### ➕ Added | ||||
|  | ||||
| - one | ||||
| - two | ||||
|  | ||||
| ### ✏️ Changed | ||||
|  | ||||
| - one | ||||
| - two | ||||
|  | ||||
| ### 🔧 Fixed | ||||
|  | ||||
| - one | ||||
| - two | ||||
|  | ||||
| ### 🗑️ Removed | ||||
|  | ||||
| - one | ||||
| - two | ||||
|  | ||||
| ## 📷 Screenshots | ||||
|  | ||||
| If this PR has any UI/UX changes it's strongly suggested you add screenshots here. | ||||
							
								
								
									
										71
									
								
								.github/workflows/docker-images.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,7 +3,7 @@ name: Make docker images | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - '*' | ||||
|       - "v*" | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
| @@ -13,11 +13,49 @@ jobs: | ||||
|       matrix: | ||||
|         include: | ||||
|           - image: henrygd/beszel | ||||
|             context: ./hub | ||||
|             dockerfile: ./hub/Dockerfile | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_hub | ||||
|             registry: docker.io | ||||
|             username_secret: DOCKERHUB_USERNAME | ||||
|             password_secret: DOCKERHUB_TOKEN | ||||
|  | ||||
|           - image: henrygd/beszel-agent | ||||
|             context: ./agent | ||||
|             dockerfile: ./agent/Dockerfile | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_agent | ||||
|             registry: docker.io | ||||
|             username_secret: DOCKERHUB_USERNAME | ||||
|             password_secret: DOCKERHUB_TOKEN | ||||
|  | ||||
|           - image: henrygd/beszel-agent-nvidia | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_agent_nvidia | ||||
|             platforms: linux/amd64 | ||||
|             registry: docker.io | ||||
|             username_secret: DOCKERHUB_USERNAME | ||||
|             password_secret: DOCKERHUB_TOKEN | ||||
|  | ||||
|           - image: ghcr.io/${{ github.repository }}/beszel | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_hub | ||||
|             registry: ghcr.io | ||||
|             username: ${{ github.actor }} | ||||
|             password_secret: GITHUB_TOKEN | ||||
|  | ||||
|           - image: ghcr.io/${{ github.repository }}/beszel-agent | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_agent | ||||
|             registry: ghcr.io | ||||
|             username: ${{ github.actor }} | ||||
|             password_secret: GITHUB_TOKEN | ||||
|  | ||||
|           - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia | ||||
|             context: ./ | ||||
|             dockerfile: ./internal/dockerfile_agent_nvidia | ||||
|             platforms: linux/amd64 | ||||
|             registry: ghcr.io | ||||
|             username: ${{ github.actor }} | ||||
|             password_secret: GITHUB_TOKEN | ||||
|  | ||||
|     permissions: | ||||
|       contents: read | ||||
|       packages: write | ||||
| @@ -30,10 +68,10 @@ jobs: | ||||
|         uses: oven-sh/setup-bun@v2 | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: bun install --no-save --cwd ./hub/site | ||||
|         run: bun install --no-save --cwd ./internal/site | ||||
|  | ||||
|       - name: Build site | ||||
|         run: bun run --cwd ./hub/site build | ||||
|         run: bun run --cwd ./internal/site build | ||||
|  | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
| @@ -47,28 +85,31 @@ jobs: | ||||
|         with: | ||||
|           images: ${{ matrix.image }} | ||||
|           tags: | | ||||
|             type=raw,value=edge | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|             type=semver,pattern={{major}} | ||||
|             type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} | ||||
|  | ||||
|       # https://github.com/docker/login-action | ||||
|       - name: Login to Docker Hub | ||||
|         if: github.event_name != 'pull_request' | ||||
|         env: | ||||
|           password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} | ||||
|         if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|           username: ${{ matrix.username || secrets[matrix.username_secret] }} | ||||
|           password: ${{ secrets[matrix.password_secret] }} | ||||
|           registry: ${{ matrix.registry }} | ||||
|  | ||||
|       # Build and push Docker image with Buildx (don't push on PR) | ||||
|       # https://github.com/docker/build-push-action | ||||
|       - name: Build and push Docker image | ||||
|         uses: docker/build-push-action@v5 | ||||
|         with: | ||||
|           context: '${{ matrix.context }}' | ||||
|           context: "${{ matrix.context }}" | ||||
|           file: ${{ matrix.dockerfile }} | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           push: ${{ github.ref_type == 'tag' }} | ||||
|           platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} | ||||
|           push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} | ||||
|           tags: ${{ steps.metadata.outputs.tags }} | ||||
|           labels: ${{ steps.metadata.outputs.labels }} | ||||
|           cache-from: type=gha | ||||
|           cache-to: type=gha | ||||
|   | ||||
							
								
								
									
										43
									
								
								.github/workflows/inactivity-actions.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,43 @@ | ||||
| name: 'Issue and PR Maintenance' | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * *'   # runs at midnight UTC | ||||
|   workflow_dispatch: | ||||
|  | ||||
| permissions: | ||||
|   issues: write | ||||
|   pull-requests: write | ||||
|  | ||||
| jobs: | ||||
|   close-stale: | ||||
|     name: Close Stale Issues | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Close Stale Issues | ||||
|         uses: actions/stale@v9 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
|           # Messaging | ||||
|           stale-issue-message: > | ||||
|             👋 This issue has been automatically marked as stale due to inactivity. | ||||
|             If this issue is still relevant, please comment to keep it open. | ||||
|             Without activity, it will be closed in 7 days. | ||||
|  | ||||
|           close-issue-message: > | ||||
|             🔒 This issue has been automatically closed due to prolonged inactivity. | ||||
|             Feel free to open a new issue if you have further questions or concerns. | ||||
|  | ||||
|           # Timing | ||||
|           days-before-issue-stale: 14 | ||||
|           days-before-issue-close: 7 | ||||
|  | ||||
|           # Labels | ||||
|           stale-issue-label: 'stale' | ||||
|           remove-stale-when-updated: true | ||||
|           only-issue-labels: 'awaiting-requester' | ||||
|  | ||||
|           # Exemptions | ||||
|           exempt-assignees: true | ||||
|           exempt-milestones: true | ||||
							
								
								
									
										82
									
								
								.github/workflows/label-from-dropdown.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,82 @@ | ||||
| name: Label issues from dropdowns | ||||
|  | ||||
| on: | ||||
|   issues: | ||||
|     types: [opened] | ||||
|  | ||||
| jobs: | ||||
|   label_from_dropdown: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       issues: write | ||||
|     steps: | ||||
|       - name: Apply labels based on dropdown choices | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|  | ||||
|             const issueNumber = context.issue.number; | ||||
|             const owner = context.repo.owner; | ||||
|             const repo = context.repo.repo; | ||||
|  | ||||
|             // Get the issue body | ||||
|             const body = context.payload.issue.body; | ||||
|  | ||||
|             // Helper to find dropdown value in the body (assuming markdown format) | ||||
|             function extractSectionValue(heading) { | ||||
|               const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i'); | ||||
|               const match = body.match(regex); | ||||
|               if (match) { | ||||
|                 // Get the first non-empty line after the heading | ||||
|                 const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean); | ||||
|                 return lines[0] || null; | ||||
|               } | ||||
|               return null; | ||||
|             } | ||||
|  | ||||
|             // Extract dropdown selections | ||||
|             const category = extractSectionValue('Category'); | ||||
|             const metrics = extractSectionValue('Affected Metrics'); | ||||
|             const component = extractSectionValue('Component'); | ||||
|  | ||||
|             // Build labels to add | ||||
|             let labelsToAdd = []; | ||||
|             if (category) labelsToAdd.push(category); | ||||
|             if (metrics) labelsToAdd.push(metrics); | ||||
|             if (component) labelsToAdd.push(component); | ||||
|  | ||||
|             // Get existing labels in the repo | ||||
|             const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({ | ||||
|               owner, | ||||
|               repo, | ||||
|               per_page: 100 | ||||
|             }); | ||||
|             const existingLabelNames = existingLabels.map(l => l.name); | ||||
|  | ||||
|             // Find labels that need to be created | ||||
|             const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label)); | ||||
|  | ||||
|             // Create missing labels (with a default color) | ||||
|             for (const label of labelsToCreate) { | ||||
|               try { | ||||
|                 await github.rest.issues.createLabel({ | ||||
|                   owner, | ||||
|                   repo, | ||||
|                   name: label, | ||||
|                   color: 'ededed' // light gray, you can pick any hex color | ||||
|                 }); | ||||
|               } catch (e) { | ||||
|                 // Ignore if label already exists (race condition), otherwise rethrow | ||||
|                 if (!e || e.status !== 422) throw e; | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             // Now apply all labels (they all exist now) | ||||
|             if (labelsToAdd.length > 0) { | ||||
|               await github.rest.issues.addLabels({ | ||||
|                 owner, | ||||
|                 repo, | ||||
|                 issue_number: issueNumber, | ||||
|                 labels: labelsToAdd | ||||
|               }); | ||||
|             }  | ||||
| @@ -3,7 +3,7 @@ name: Make release and binaries | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - '*' | ||||
|       - "v*" | ||||
| 
 | ||||
| permissions: | ||||
|   contents: write | ||||
| @@ -21,32 +21,34 @@ jobs: | ||||
|         uses: oven-sh/setup-bun@v2 | ||||
| 
 | ||||
|       - name: Install dependencies | ||||
|         run: bun install --no-save --cwd ./hub/site | ||||
|         run: bun install --no-save --cwd ./internal/site | ||||
| 
 | ||||
|       - name: Build site | ||||
|         run: bun run --cwd ./hub/site build | ||||
|         run: bun run --cwd ./internal/site build | ||||
| 
 | ||||
|       - name: Set up Go | ||||
|         uses: actions/setup-go@v5 | ||||
|         with: | ||||
|           go-version: '^1.22.1' | ||||
|           go-version: "^1.22.1" | ||||
| 
 | ||||
|       - name: Set up .NET | ||||
|         uses: actions/setup-dotnet@v4 | ||||
|         with: | ||||
|           dotnet-version: "9.0.x" | ||||
| 
 | ||||
|       - name: Build .NET LHM executable for Windows sensors | ||||
|         run: | | ||||
|           dotnet build -c Release ./agent/lhm/beszel_lhm.csproj | ||||
|         shell: bash | ||||
| 
 | ||||
|       - name: GoReleaser beszel | ||||
|         uses: goreleaser/goreleaser-action@v6 | ||||
|         with: | ||||
|           workdir: ./hub | ||||
|           workdir: ./ | ||||
|           distribution: goreleaser | ||||
|           version: latest | ||||
|           args: release --clean | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.TOKEN }} | ||||
| 
 | ||||
|       - name: GoReleaser beszel-agent | ||||
|         uses: goreleaser/goreleaser-action@v6 | ||||
|         with: | ||||
|           workdir: ./agent | ||||
|           distribution: goreleaser | ||||
|           version: latest | ||||
|           args: release --clean | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.TOKEN }} | ||||
|           GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }} | ||||
|           WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} | ||||
|           IS_FORK: ${{ github.repository_owner != 'henrygd' }} | ||||
							
								
								
									
										33
									
								
								.github/workflows/vulncheck.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | ||||
| # https://github.com/minio/minio/blob/master/.github/workflows/vulncheck.yml | ||||
|  | ||||
| name: VulnCheck | ||||
| on: | ||||
|   pull_request: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
| permissions: | ||||
|   contents: read # to fetch code (actions/checkout) | ||||
|  | ||||
| jobs: | ||||
|   vulncheck: | ||||
|     name: VulnCheck | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out code into the Go module directory | ||||
|         uses: actions/checkout@v4 | ||||
|       - name: Set up Go | ||||
|         uses: actions/setup-go@v5 | ||||
|         with: | ||||
|           go-version: 1.25.x | ||||
|           # cached: false | ||||
|       - name: Get official govulncheck | ||||
|         run: go install golang.org/x/vuln/cmd/govulncheck@latest | ||||
|         shell: bash | ||||
|       - name: Run govulncheck | ||||
|         run: govulncheck -show verbose ./... | ||||
|         shell: bash | ||||
							
								
								
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,8 +3,20 @@ pb_data | ||||
| data | ||||
| temp | ||||
| .vscode | ||||
| beszel | ||||
| beszel-agent | ||||
| beszel_data | ||||
| beszel_data* | ||||
| dist | ||||
| dist | ||||
| *.exe | ||||
| internal/cmd/hub/hub | ||||
| internal/cmd/agent/agent | ||||
| node_modules | ||||
| build | ||||
| *timestamp* | ||||
| .swc | ||||
| internal/site/src/locales/**/*.ts | ||||
| *.bak | ||||
| __debug_* | ||||
| agent/lhm/obj | ||||
| agent/lhm/bin | ||||
| dockerfile_agent_dev | ||||
|   | ||||
							
								
								
									
										237
									
								
								.goreleaser.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,237 @@ | ||||
| version: 2 | ||||
|  | ||||
| project_name: beszel | ||||
|  | ||||
| before: | ||||
|   hooks: | ||||
|     - go mod tidy | ||||
|  | ||||
| builds: | ||||
|   - id: beszel | ||||
|     binary: beszel | ||||
|     main: internal/cmd/hub/hub.go | ||||
|     env: | ||||
|       - CGO_ENABLED=0 | ||||
|     goos: | ||||
|       - linux | ||||
|       - darwin | ||||
|     goarch: | ||||
|       - amd64 | ||||
|       - arm64 | ||||
|       - arm | ||||
|  | ||||
|   - id: beszel-agent | ||||
|     binary: beszel-agent | ||||
|     main: internal/cmd/agent/agent.go | ||||
|     env: | ||||
|       - CGO_ENABLED=0 | ||||
|     goos: | ||||
|       - linux | ||||
|       - darwin | ||||
|       - freebsd | ||||
|       - openbsd | ||||
|       - windows | ||||
|     goarch: | ||||
|       - amd64 | ||||
|       - arm64 | ||||
|       - arm | ||||
|       - mips64 | ||||
|       - riscv64 | ||||
|       - mipsle | ||||
|       - mips | ||||
|       - ppc64le | ||||
|     gomips: | ||||
|       - hardfloat | ||||
|       - softfloat | ||||
|     ignore: | ||||
|       - goos: freebsd | ||||
|         goarch: arm | ||||
|       - goos: openbsd | ||||
|         goarch: arm | ||||
|       - goos: linux | ||||
|         goarch: mips64 | ||||
|         gomips: softfloat | ||||
|       - goos: linux | ||||
|         goarch: mipsle | ||||
|         gomips: hardfloat | ||||
|       - goos: linux | ||||
|         goarch: mips | ||||
|         gomips: hardfloat | ||||
|       - goos: windows | ||||
|         goarch: arm | ||||
|       - goos: darwin | ||||
|         goarch: riscv64 | ||||
|       - goos: windows | ||||
|         goarch: riscv64 | ||||
|  | ||||
| archives: | ||||
|   - id: beszel-agent | ||||
|     formats: [tar.gz] | ||||
|     ids: | ||||
|       - beszel-agent | ||||
|     name_template: >- | ||||
|       {{ .Binary }}_ | ||||
|       {{- .Os }}_ | ||||
|       {{- .Arch }} | ||||
|     format_overrides: | ||||
|       - goos: windows | ||||
|         formats: [zip] | ||||
|  | ||||
|   - id: beszel | ||||
|     formats: [tar.gz] | ||||
|     ids: | ||||
|       - beszel | ||||
|     name_template: >- | ||||
|       {{ .Binary }}_ | ||||
|       {{- .Os }}_ | ||||
|       {{- .Arch }} | ||||
|  | ||||
| nfpms: | ||||
|   - id: beszel-agent | ||||
|     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 | ||||
|     ids: | ||||
|       - beszel-agent | ||||
|     formats: | ||||
|       - deb | ||||
|     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 | ||||
|  | ||||
| scoops: | ||||
|   - ids: [beszel-agent] | ||||
|     name: beszel-agent | ||||
|     repository: | ||||
|       owner: henrygd | ||||
|       name: beszel-scoops | ||||
|     homepage: "https://beszel.dev" | ||||
|     description: "Agent for Beszel, a lightweight server monitoring platform." | ||||
|     license: MIT | ||||
|     skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' | ||||
|  | ||||
| # # Needs choco installed, so doesn't build on linux / default gh workflow :( | ||||
| # chocolateys: | ||||
| #   - title: Beszel Agent | ||||
| #     ids: [beszel-agent] | ||||
| #     package_source_url: https://github.com/henrygd/beszel-chocolatey | ||||
| #     owners: henrygd | ||||
| #     authors: henrygd | ||||
| #     summary: 'Agent for Beszel, a lightweight server monitoring platform.' | ||||
| #     description: | | ||||
| #       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. | ||||
| #     license_url: https://github.com/henrygd/beszel/blob/main/LICENSE | ||||
| #     project_url: https://beszel.dev | ||||
| #     project_source_url: https://github.com/henrygd/beszel | ||||
| #     docs_url: https://beszel.dev/guide/getting-started | ||||
| #     icon_url: https://cdn.jsdelivr.net/gh/selfhst/icons/png/beszel.png | ||||
| #     bug_tracker_url: https://github.com/henrygd/beszel/issues | ||||
| #     copyright: 2025 henrygd | ||||
| #     tags: foss cross-platform admin monitoring | ||||
| #     require_license_acceptance: false | ||||
| #     release_notes: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}' | ||||
|  | ||||
| brews: | ||||
|   - ids: [beszel-agent] | ||||
|     name: beszel-agent | ||||
|     repository: | ||||
|       owner: henrygd | ||||
|       name: homebrew-beszel | ||||
|     homepage: "https://beszel.dev" | ||||
|     description: "Agent for Beszel, a lightweight server monitoring platform." | ||||
|     license: MIT | ||||
|     skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' | ||||
|     extra_install: | | ||||
|       (bin/"beszel-agent-launcher").write <<~EOS | ||||
|         #!/bin/bash | ||||
|         set -a | ||||
|         if [ -f "$HOME/.config/beszel/beszel-agent.env" ]; then | ||||
|           source "$HOME/.config/beszel/beszel-agent.env" | ||||
|         fi | ||||
|         set +a | ||||
|         exec #{bin}/beszel-agent "$@" | ||||
|       EOS | ||||
|       (bin/"beszel-agent-launcher").chmod 0755 | ||||
|     service: | | ||||
|       run ["#{bin}/beszel-agent-launcher"] | ||||
|       log_path "#{Dir.home}/.cache/beszel/beszel-agent.log" | ||||
|       error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log" | ||||
|       keep_alive true | ||||
|       restart_delay 5 | ||||
|       process_type :background | ||||
|  | ||||
| winget: | ||||
|   - ids: [beszel-agent] | ||||
|     name: beszel-agent | ||||
|     package_identifier: henrygd.beszel-agent | ||||
|     publisher: henrygd | ||||
|     license: MIT | ||||
|     license_url: "https://github.com/henrygd/beszel/blob/main/LICENSE" | ||||
|     copyright: "2025 henrygd" | ||||
|     homepage: "https://beszel.dev" | ||||
|     release_notes_url: "https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}" | ||||
|     publisher_support_url: "https://github.com/henrygd/beszel/issues" | ||||
|     short_description: "Agent for Beszel, a lightweight server monitoring platform." | ||||
|     skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' | ||||
|     description: | | ||||
|       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. | ||||
|     tags: | ||||
|       - homelab | ||||
|       - monitoring | ||||
|       - self-hosted | ||||
|     repository: | ||||
|       owner: henrygd | ||||
|       name: beszel-winget | ||||
|       branch: henrygd.beszel-agent-{{ .Version }} | ||||
|       token: "{{ .Env.WINGET_TOKEN }}" | ||||
|       # pull_request: | ||||
|       #   enabled: true | ||||
|       #   draft: false | ||||
|       #   base: | ||||
|       #     owner: microsoft | ||||
|       #     name: winget-pkgs | ||||
|       #     branch: master | ||||
|  | ||||
| release: | ||||
|   draft: true | ||||
|  | ||||
| changelog: | ||||
|   disable: true | ||||
|   sort: asc | ||||
|   filters: | ||||
|     exclude: | ||||
|       - "^docs:" | ||||
|       - "^test:" | ||||
							
								
								
									
										102
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,102 @@ | ||||
| # Default OS/ARCH values | ||||
| OS ?= $(shell go env GOOS) | ||||
| ARCH ?= $(shell go env GOARCH) | ||||
| # Skip building the web UI if true | ||||
| SKIP_WEB ?= false | ||||
|  | ||||
| # Set executable extension based on target OS | ||||
| EXE_EXT := $(if $(filter windows,$(OS)),.exe,) | ||||
|  | ||||
| .PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales | ||||
| .DEFAULT_GOAL := build | ||||
|  | ||||
| clean: | ||||
| 	go clean | ||||
| 	rm -rf ./build | ||||
|  | ||||
| lint: | ||||
| 	golangci-lint run | ||||
|  | ||||
| test: export GOEXPERIMENT=synctest | ||||
| test: | ||||
| 	go test -tags=testing ./... | ||||
|  | ||||
| tidy: | ||||
| 	go mod tidy | ||||
|  | ||||
| build-web-ui: | ||||
| 	@if command -v bun >/dev/null 2>&1; then \ | ||||
| 		bun install --cwd ./internal/site && \ | ||||
| 		bun run --cwd ./internal/site build; \ | ||||
| 	else \ | ||||
| 		npm install --prefix ./internal/site && \ | ||||
| 		npm run --prefix ./internal/site build; \ | ||||
| 	fi | ||||
|  | ||||
| # Conditional .NET build - only for Windows | ||||
| build-dotnet-conditional: | ||||
| 	@if [ "$(OS)" = "windows" ]; then \ | ||||
| 		echo "Building .NET executable for Windows..."; \ | ||||
| 		if command -v dotnet >/dev/null 2>&1; then \ | ||||
| 			rm -rf ./agent/lhm/bin; \ | ||||
| 			dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \ | ||||
| 		else \ | ||||
| 			echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \ | ||||
| 			exit 1; \ | ||||
| 		fi; \ | ||||
| 	fi | ||||
|  | ||||
| # Update build-agent to include conditional .NET build | ||||
| build-agent: tidy build-dotnet-conditional | ||||
| 	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent | ||||
|  | ||||
| build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui) | ||||
| 	GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub | ||||
|  | ||||
| build-hub-dev: tidy | ||||
| 	mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html | ||||
| 	GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub | ||||
|  | ||||
| build: build-agent build-hub | ||||
|  | ||||
| generate-locales: | ||||
| 	@if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \ | ||||
| 		echo "Generating locales..."; \ | ||||
| 		command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \ | ||||
| 	fi | ||||
|  | ||||
| dev-server: generate-locales | ||||
| 	cd ./internal/site | ||||
| 	@if command -v bun >/dev/null 2>&1; then \ | ||||
| 		cd ./internal/site && bun run dev --host 0.0.0.0; \ | ||||
| 	else \ | ||||
| 		cd ./internal/site && npm run dev --host 0.0.0.0; \ | ||||
| 	fi | ||||
|  | ||||
| dev-hub: export ENV=dev | ||||
| dev-hub: | ||||
| 	mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html | ||||
| 	@if command -v entr >/dev/null 2>&1; then \ | ||||
| 		find ./internal/cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \ | ||||
| 	else \ | ||||
| 		cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \ | ||||
| 	fi | ||||
|  | ||||
| dev-agent: | ||||
| 	@if command -v entr >/dev/null 2>&1; then \ | ||||
| 		find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \ | ||||
| 	else \ | ||||
| 		go run github.com/henrygd/beszel/internal/cmd/agent; \ | ||||
| 	fi | ||||
| 	 | ||||
| build-dotnet: | ||||
| 	@if command -v dotnet >/dev/null 2>&1; then \ | ||||
| 		rm -rf ./agent/lhm/bin; \ | ||||
| 		dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \ | ||||
| 	else \ | ||||
| 		echo "dotnet not found"; \ | ||||
| 	fi | ||||
|  | ||||
|  | ||||
| # KEY="..." make -j dev | ||||
| dev: dev-server dev-hub dev-agent | ||||
							
								
								
									
										7
									
								
								SECURITY.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| # Security Policy | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
| If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new). | ||||
|  | ||||
| If it's low severity (use best judgement) you may open an issue instead of an advisory. | ||||
| @@ -1,36 +0,0 @@ | ||||
| # version: 1 | ||||
|  | ||||
| project_name: beszel-agent | ||||
|  | ||||
| before: | ||||
|   hooks: | ||||
|     - go mod tidy | ||||
|  | ||||
| builds: | ||||
|   - env: | ||||
|       - CGO_ENABLED=0 | ||||
|     goos: | ||||
|       - linux | ||||
|       - darwin | ||||
|     goarch: | ||||
|       - amd64 | ||||
|       - arm64 | ||||
|  | ||||
| archives: | ||||
|   - format: tar.gz | ||||
|     name_template: >- | ||||
|       {{ .ProjectName }}_ | ||||
|       {{- .Os }}_ | ||||
|       {{- .Arch }} | ||||
|     # use zip for windows archives | ||||
|     format_overrides: | ||||
|       - goos: windows | ||||
|         format: zip | ||||
|  | ||||
| changelog: | ||||
|   disable: true | ||||
|   sort: asc | ||||
|   filters: | ||||
|     exclude: | ||||
|       - '^docs:' | ||||
|       - '^test:' | ||||
							
								
								
									
										185
									
								
								agent/agent.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,185 @@ | ||||
| // Package agent implements the Beszel monitoring agent that collects and serves system metrics. | ||||
| // | ||||
| // The agent runs on monitored systems and communicates collected data | ||||
| // to the Beszel hub for centralized monitoring and alerting. | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"crypto/sha256" | ||||
| 	"encoding/hex" | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gliderlabs/ssh" | ||||
| 	"github.com/henrygd/beszel" | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
| 	"github.com/shirou/gopsutil/v4/host" | ||||
| 	gossh "golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| type Agent struct { | ||||
| 	sync.Mutex                                   // Used to lock agent while collecting data | ||||
| 	debug             bool                       // true if LOG_LEVEL is set to debug | ||||
| 	zfs               bool                       // true if system has arcstats | ||||
| 	memCalc           string                     // Memory calculation formula | ||||
| 	fsNames           []string                   // List of filesystem device names being monitored | ||||
| 	fsStats           map[string]*system.FsStats // Keeps track of disk stats for each filesystem | ||||
| 	netInterfaces     map[string]struct{}        // Stores all valid network interfaces | ||||
| 	netIoStats        system.NetIoStats          // Keeps track of bandwidth usage | ||||
| 	dockerManager     *dockerManager             // Manages Docker API requests | ||||
| 	sensorConfig      *SensorConfig              // Sensors config | ||||
| 	systemInfo        system.Info                // Host system info | ||||
| 	gpuManager        *GPUManager                // Manages GPU data | ||||
| 	cache             *SessionCache              // Cache for system stats based on primary session ID | ||||
| 	connectionManager *ConnectionManager         // Channel to signal connection events | ||||
| 	server            *ssh.Server                // SSH server | ||||
| 	dataDir           string                     // Directory for persisting data | ||||
| 	keys              []gossh.PublicKey          // SSH public keys | ||||
| } | ||||
|  | ||||
| // NewAgent creates a new agent with the given data directory for persisting data. | ||||
| // If the data directory is not set, it will attempt to find the optimal directory. | ||||
| func NewAgent(dataDir ...string) (agent *Agent, err error) { | ||||
| 	agent = &Agent{ | ||||
| 		fsStats: make(map[string]*system.FsStats), | ||||
| 		cache:   NewSessionCache(69 * time.Second), | ||||
| 	} | ||||
|  | ||||
| 	agent.dataDir, err = getDataDir(dataDir...) | ||||
| 	if err != nil { | ||||
| 		slog.Warn("Data directory not found") | ||||
| 	} else { | ||||
| 		slog.Info("Data directory", "path", agent.dataDir) | ||||
| 	} | ||||
|  | ||||
| 	agent.memCalc, _ = GetEnv("MEM_CALC") | ||||
| 	agent.sensorConfig = agent.newSensorConfig() | ||||
| 	// Set up slog with a log level determined by the LOG_LEVEL env var | ||||
| 	if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists { | ||||
| 		switch strings.ToLower(logLevelStr) { | ||||
| 		case "debug": | ||||
| 			agent.debug = true | ||||
| 			slog.SetLogLoggerLevel(slog.LevelDebug) | ||||
| 		case "warn": | ||||
| 			slog.SetLogLoggerLevel(slog.LevelWarn) | ||||
| 		case "error": | ||||
| 			slog.SetLogLoggerLevel(slog.LevelError) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	slog.Debug(beszel.Version) | ||||
|  | ||||
| 	// initialize system info | ||||
| 	agent.initializeSystemInfo() | ||||
|  | ||||
| 	// initialize connection manager | ||||
| 	agent.connectionManager = newConnectionManager(agent) | ||||
|  | ||||
| 	// initialize disk info | ||||
| 	agent.initializeDiskInfo() | ||||
|  | ||||
| 	// initialize net io stats | ||||
| 	agent.initializeNetIoStats() | ||||
|  | ||||
| 	// initialize docker manager | ||||
| 	agent.dockerManager = newDockerManager(agent) | ||||
|  | ||||
| 	// initialize GPU manager | ||||
| 	if gm, err := NewGPUManager(); err != nil { | ||||
| 		slog.Debug("GPU", "err", err) | ||||
| 	} else { | ||||
| 		agent.gpuManager = gm | ||||
| 	} | ||||
|  | ||||
| 	// if debugging, print stats | ||||
| 	if agent.debug { | ||||
| 		slog.Debug("Stats", "data", agent.gatherStats("")) | ||||
| 	} | ||||
|  | ||||
| 	return agent, nil | ||||
| } | ||||
|  | ||||
| // 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(sessionID string) *system.CombinedData { | ||||
| 	a.Lock() | ||||
| 	defer a.Unlock() | ||||
|  | ||||
| 	data, isCached := a.cache.Get(sessionID) | ||||
| 	if isCached { | ||||
| 		slog.Debug("Cached data", "session", sessionID) | ||||
| 		return data | ||||
| 	} | ||||
|  | ||||
| 	*data = system.CombinedData{ | ||||
| 		Stats: a.getSystemStats(), | ||||
| 		Info:  a.systemInfo, | ||||
| 	} | ||||
| 	slog.Debug("System data", "data", data) | ||||
|  | ||||
| 	if a.dockerManager != nil { | ||||
| 		if containerStats, err := a.dockerManager.getDockerStats(); err == nil { | ||||
| 			data.Containers = containerStats | ||||
| 			slog.Debug("Containers", "data", data.Containers) | ||||
| 		} else { | ||||
| 			slog.Debug("Containers", "err", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	data.Stats.ExtraFs = make(map[string]*system.FsStats) | ||||
| 	for name, stats := range a.fsStats { | ||||
| 		if !stats.Root && stats.DiskTotal > 0 { | ||||
| 			data.Stats.ExtraFs[name] = stats | ||||
| 		} | ||||
| 	} | ||||
| 	slog.Debug("Extra FS", "data", data.Stats.ExtraFs) | ||||
|  | ||||
| 	a.cache.Set(sessionID, data) | ||||
| 	return data | ||||
| } | ||||
|  | ||||
| // StartAgent initializes and starts the agent with optional WebSocket connection | ||||
| func (a *Agent) Start(serverOptions ServerOptions) error { | ||||
| 	a.keys = serverOptions.Keys | ||||
| 	return a.connectionManager.Start(serverOptions) | ||||
| } | ||||
|  | ||||
| func (a *Agent) getFingerprint() string { | ||||
| 	// first look for a fingerprint in the data directory | ||||
| 	if a.dataDir != "" { | ||||
| 		if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil { | ||||
| 			return string(fp) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// if no fingerprint is found, generate one | ||||
| 	fingerprint, err := host.HostID() | ||||
| 	if err != nil || fingerprint == "" { | ||||
| 		fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel | ||||
| 	} | ||||
|  | ||||
| 	// hash fingerprint | ||||
| 	sum := sha256.Sum256([]byte(fingerprint)) | ||||
| 	fingerprint = hex.EncodeToString(sum[:24]) | ||||
|  | ||||
| 	// save fingerprint to data directory | ||||
| 	if a.dataDir != "" { | ||||
| 		err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644) | ||||
| 		if err != nil { | ||||
| 			slog.Warn("Failed to save fingerprint", "err", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return fingerprint | ||||
| } | ||||
							
								
								
									
										37
									
								
								agent/agent_cache.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,37 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
| ) | ||||
|  | ||||
| // Not thread safe since we only access from gatherStats which is already locked | ||||
| type SessionCache struct { | ||||
| 	data           *system.CombinedData | ||||
| 	lastUpdate     time.Time | ||||
| 	primarySession string | ||||
| 	leaseTime      time.Duration | ||||
| } | ||||
|  | ||||
| func NewSessionCache(leaseTime time.Duration) *SessionCache { | ||||
| 	return &SessionCache{ | ||||
| 		leaseTime: leaseTime, | ||||
| 		data:      &system.CombinedData{}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) { | ||||
| 	if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime { | ||||
| 		return c.data, true | ||||
| 	} | ||||
| 	return c.data, false | ||||
| } | ||||
|  | ||||
| func (c *SessionCache) Set(sessionID string, data *system.CombinedData) { | ||||
| 	if data != nil { | ||||
| 		*c.data = *data | ||||
| 	} | ||||
| 	c.primarySession = sessionID | ||||
| 	c.lastUpdate = time.Now() | ||||
| } | ||||
							
								
								
									
										89
									
								
								agent/agent_cache_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,89 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
| 	"testing/synctest" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestSessionCache_GetSet(t *testing.T) { | ||||
| 	synctest.Test(t, func(t *testing.T) { | ||||
| 		cache := NewSessionCache(69 * time.Second) | ||||
|  | ||||
| 		testData := &system.CombinedData{ | ||||
| 			Info: system.Info{ | ||||
| 				Hostname: "test-host", | ||||
| 				Cores:    4, | ||||
| 			}, | ||||
| 			Stats: system.Stats{ | ||||
| 				Cpu:     50.0, | ||||
| 				MemPct:  30.0, | ||||
| 				DiskPct: 40.0, | ||||
| 			}, | ||||
| 		} | ||||
|  | ||||
| 		// Test initial state - should not be cached | ||||
| 		data, isCached := cache.Get("session1") | ||||
| 		assert.False(t, isCached, "Expected no cached data initially") | ||||
| 		assert.NotNil(t, data, "Expected data to be initialized") | ||||
| 		// Set data for session1 | ||||
| 		cache.Set("session1", testData) | ||||
|  | ||||
| 		time.Sleep(15 * time.Second) | ||||
|  | ||||
| 		// Get data for a different session - should be cached | ||||
| 		data, isCached = cache.Get("session2") | ||||
| 		assert.True(t, isCached, "Expected data to be cached for non-primary session") | ||||
| 		require.NotNil(t, data, "Expected cached data to be returned") | ||||
| 		assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data") | ||||
| 		assert.Equal(t, 4, data.Info.Cores, "Cores should match test data") | ||||
| 		assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data") | ||||
| 		assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data") | ||||
| 		assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data") | ||||
|  | ||||
| 		time.Sleep(10 * time.Second) | ||||
|  | ||||
| 		// Get data for the primary session - should not be cached | ||||
| 		data, isCached = cache.Get("session1") | ||||
| 		assert.False(t, isCached, "Expected data not to be cached for primary session") | ||||
| 		require.NotNil(t, data, "Expected data to be returned even if not cached") | ||||
| 		assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data") | ||||
| 		// if not cached, agent will update the data | ||||
| 		cache.Set("session1", testData) | ||||
|  | ||||
| 		time.Sleep(45 * time.Second) | ||||
|  | ||||
| 		// Get data for a different session - should still be cached | ||||
| 		_, isCached = cache.Get("session2") | ||||
| 		assert.True(t, isCached, "Expected data to be cached for non-primary session") | ||||
|  | ||||
| 		// Wait for the lease to expire | ||||
| 		time.Sleep(30 * time.Second) | ||||
|  | ||||
| 		// Get data for session2 - should not be cached | ||||
| 		_, isCached = cache.Get("session2") | ||||
| 		assert.False(t, isCached, "Expected data not to be cached after lease expiration") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestSessionCache_NilData(t *testing.T) { | ||||
| 	// Create a new SessionCache | ||||
| 	cache := NewSessionCache(30 * time.Second) | ||||
|  | ||||
| 	// Test setting nil data (should not panic) | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cache.Set("session1", nil) | ||||
| 	}, "Setting nil data should not panic") | ||||
|  | ||||
| 	// Get data - should not be nil even though we set nil | ||||
| 	data, _ := cache.Get("session2") | ||||
| 	assert.NotNil(t, data, "Expected data to not be nil after setting nil data") | ||||
| } | ||||
							
								
								
									
										9
									
								
								agent/agent_test_helpers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| // TESTING ONLY: GetConnectionManager is a helper function to get the connection manager for testing. | ||||
| func (a *Agent) GetConnectionManager() *ConnectionManager { | ||||
| 	return a.connectionManager | ||||
| } | ||||
							
								
								
									
										52
									
								
								agent/battery/battery.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
| //go:build !freebsd | ||||
|  | ||||
| // Package battery provides functions to check if the system has a battery and to get the battery stats. | ||||
| package battery | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"log/slog" | ||||
|  | ||||
| 	"github.com/distatus/battery" | ||||
| ) | ||||
|  | ||||
| var systemHasBattery = false | ||||
| var haveCheckedBattery = false | ||||
|  | ||||
| // HasReadableBattery checks if the system has a battery and returns true if it does. | ||||
| func HasReadableBattery() bool { | ||||
| 	if haveCheckedBattery { | ||||
| 		return systemHasBattery | ||||
| 	} | ||||
| 	haveCheckedBattery = true | ||||
| 	bat, err := battery.Get(0) | ||||
| 	systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0 | ||||
| 	if !systemHasBattery { | ||||
| 		slog.Debug("No battery found", "err", err) | ||||
| 	} | ||||
| 	return systemHasBattery | ||||
| } | ||||
|  | ||||
| // GetBatteryStats returns the current battery percent and charge state | ||||
| func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) { | ||||
| 	if !systemHasBattery { | ||||
| 		return batteryPercent, batteryState, errors.ErrUnsupported | ||||
| 	} | ||||
| 	batteries, err := battery.GetAll() | ||||
| 	if err != nil || len(batteries) == 0 { | ||||
| 		return batteryPercent, batteryState, err | ||||
| 	} | ||||
| 	totalCapacity := float64(0) | ||||
| 	totalCharge := float64(0) | ||||
| 	for _, bat := range batteries { | ||||
| 		if bat.Design != 0 { | ||||
| 			totalCapacity += bat.Design | ||||
| 		} else { | ||||
| 			totalCapacity += bat.Full | ||||
| 		} | ||||
| 		totalCharge += bat.Current | ||||
| 	} | ||||
| 	batteryPercent = uint8(totalCharge / totalCapacity * 100) | ||||
| 	batteryState = uint8(batteries[0].State.Raw) | ||||
| 	return batteryPercent, batteryState, nil | ||||
| } | ||||
							
								
								
									
										13
									
								
								agent/battery/battery_freebsd.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| //go:build freebsd | ||||
|  | ||||
| package battery | ||||
|  | ||||
| import "errors" | ||||
|  | ||||
| func HasReadableBattery() bool { | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func GetBatteryStats() (uint8, uint8, error) { | ||||
| 	return 0, 0, errors.ErrUnsupported | ||||
| } | ||||
							
								
								
									
										266
									
								
								agent/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,266 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel" | ||||
| 	"github.com/henrygd/beszel/internal/common" | ||||
|  | ||||
| 	"github.com/fxamacker/cbor/v2" | ||||
| 	"github.com/lxzan/gws" | ||||
| 	"golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	wsDeadline = 70 * time.Second | ||||
| ) | ||||
|  | ||||
| // WebSocketClient manages the WebSocket connection between the agent and hub. | ||||
| // It handles authentication, message routing, and connection lifecycle management. | ||||
| type WebSocketClient struct { | ||||
| 	gws.BuiltinEventHandler | ||||
| 	options            *gws.ClientOption                   // WebSocket client configuration options | ||||
| 	agent              *Agent                              // Reference to the parent agent | ||||
| 	Conn               *gws.Conn                           // Active WebSocket connection | ||||
| 	hubURL             *url.URL                            // Parsed hub URL for connection | ||||
| 	token              string                              // Authentication token for hub registration | ||||
| 	fingerprint        string                              // System fingerprint for identification | ||||
| 	hubRequest         *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing | ||||
| 	lastConnectAttempt time.Time                           // Timestamp of last connection attempt | ||||
| 	hubVerified        bool                                // Whether the hub has been cryptographically verified | ||||
| } | ||||
|  | ||||
| // newWebSocketClient creates a new WebSocket client for the given agent. | ||||
| // It reads configuration from environment variables and validates the hub URL. | ||||
| func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) { | ||||
| 	hubURLStr, exists := GetEnv("HUB_URL") | ||||
| 	if !exists { | ||||
| 		return nil, errors.New("HUB_URL environment variable not set") | ||||
| 	} | ||||
|  | ||||
| 	client = &WebSocketClient{} | ||||
|  | ||||
| 	client.hubURL, err = url.Parse(hubURLStr) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.New("invalid hub URL") | ||||
| 	} | ||||
| 	// get registration token | ||||
| 	client.token, err = getToken() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	client.agent = agent | ||||
| 	client.hubRequest = &common.HubRequest[cbor.RawMessage]{} | ||||
| 	client.fingerprint = agent.getFingerprint() | ||||
|  | ||||
| 	return client, nil | ||||
| } | ||||
|  | ||||
| // getToken returns the token for the WebSocket client. | ||||
| // It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable. | ||||
| // If neither is set, it returns an error. | ||||
| func getToken() (string, error) { | ||||
| 	// get token from env var | ||||
| 	token, _ := GetEnv("TOKEN") | ||||
| 	if token != "" { | ||||
| 		return token, nil | ||||
| 	} | ||||
| 	// get token from file | ||||
| 	tokenFile, _ := GetEnv("TOKEN_FILE") | ||||
| 	if tokenFile == "" { | ||||
| 		return "", errors.New("must set TOKEN or TOKEN_FILE") | ||||
| 	} | ||||
| 	tokenBytes, err := os.ReadFile(tokenFile) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	return strings.TrimSpace(string(tokenBytes)), nil | ||||
| } | ||||
|  | ||||
| // getOptions returns the WebSocket client options, creating them if necessary. | ||||
| // It configures the connection URL, TLS settings, and authentication headers. | ||||
| func (client *WebSocketClient) getOptions() *gws.ClientOption { | ||||
| 	if client.options != nil { | ||||
| 		return client.options | ||||
| 	} | ||||
|  | ||||
| 	// update the hub url to use websocket scheme and api path | ||||
| 	if client.hubURL.Scheme == "https" { | ||||
| 		client.hubURL.Scheme = "wss" | ||||
| 	} else { | ||||
| 		client.hubURL.Scheme = "ws" | ||||
| 	} | ||||
| 	client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect") | ||||
|  | ||||
| 	client.options = &gws.ClientOption{ | ||||
| 		Addr:      client.hubURL.String(), | ||||
| 		TlsConfig: &tls.Config{InsecureSkipVerify: true}, | ||||
| 		RequestHeader: http.Header{ | ||||
| 			"User-Agent": []string{getUserAgent()}, | ||||
| 			"X-Token":    []string{client.token}, | ||||
| 			"X-Beszel":   []string{beszel.Version}, | ||||
| 		}, | ||||
| 	} | ||||
| 	return client.options | ||||
| } | ||||
|  | ||||
| // Connect establishes a WebSocket connection to the hub. | ||||
| // It closes any existing connection before attempting to reconnect. | ||||
| func (client *WebSocketClient) Connect() (err error) { | ||||
| 	client.lastConnectAttempt = time.Now() | ||||
|  | ||||
| 	// make sure previous connection is closed | ||||
| 	client.Close() | ||||
|  | ||||
| 	client.Conn, _, err = gws.NewClient(client, client.getOptions()) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	go client.Conn.ReadLoop() | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // OnOpen handles WebSocket connection establishment. | ||||
| // It sets a deadline for the connection to prevent hanging. | ||||
| func (client *WebSocketClient) OnOpen(conn *gws.Conn) { | ||||
| 	conn.SetDeadline(time.Now().Add(wsDeadline)) | ||||
| } | ||||
|  | ||||
| // OnClose handles WebSocket connection closure. | ||||
| // It logs the closure reason and notifies the connection manager. | ||||
| func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) { | ||||
| 	slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: ")) | ||||
| 	client.agent.connectionManager.eventChan <- WebSocketDisconnect | ||||
| } | ||||
|  | ||||
| // OnMessage handles incoming WebSocket messages from the hub. | ||||
| // It decodes CBOR messages and routes them to appropriate handlers. | ||||
| func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) { | ||||
| 	defer message.Close() | ||||
| 	conn.SetDeadline(time.Now().Add(wsDeadline)) | ||||
|  | ||||
| 	if message.Opcode != gws.OpcodeBinary { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil { | ||||
| 		slog.Error("Error parsing message", "err", err) | ||||
| 		return | ||||
| 	} | ||||
| 	if err := client.handleHubRequest(client.hubRequest); err != nil { | ||||
| 		slog.Error("Error handling message", "err", err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // OnPing handles WebSocket ping frames. | ||||
| // It responds with a pong and updates the connection deadline. | ||||
| func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) { | ||||
| 	conn.SetDeadline(time.Now().Add(wsDeadline)) | ||||
| 	conn.WritePong(message) | ||||
| } | ||||
|  | ||||
| // handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint. | ||||
| func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) { | ||||
| 	var authRequest common.FingerprintRequest | ||||
| 	if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := client.verifySignature(authRequest.Signature); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	client.hubVerified = true | ||||
| 	client.agent.connectionManager.eventChan <- WebSocketConnect | ||||
|  | ||||
| 	response := &common.FingerprintResponse{ | ||||
| 		Fingerprint: client.fingerprint, | ||||
| 	} | ||||
|  | ||||
| 	if authRequest.NeedSysInfo { | ||||
| 		response.Hostname = client.agent.systemInfo.Hostname | ||||
| 		serverAddr := client.agent.connectionManager.serverOptions.Addr | ||||
| 		_, response.Port, _ = net.SplitHostPort(serverAddr) | ||||
| 	} | ||||
|  | ||||
| 	return client.sendMessage(response) | ||||
| } | ||||
|  | ||||
| // verifySignature verifies the signature of the token using the public keys. | ||||
| func (client *WebSocketClient) verifySignature(signature []byte) (err error) { | ||||
| 	for _, pubKey := range client.agent.keys { | ||||
| 		sig := ssh.Signature{ | ||||
| 			Format: pubKey.Type(), | ||||
| 			Blob:   signature, | ||||
| 		} | ||||
| 		if err = pubKey.Verify([]byte(client.token), &sig); err == nil { | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
| 	return errors.New("invalid signature - check KEY value") | ||||
| } | ||||
|  | ||||
| // Close closes the WebSocket connection gracefully. | ||||
| // This method is safe to call multiple times. | ||||
| func (client *WebSocketClient) Close() { | ||||
| 	if client.Conn != nil { | ||||
| 		_ = client.Conn.WriteClose(1000, nil) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // handleHubRequest routes the request to the appropriate handler. | ||||
| // It ensures the hub is verified before processing most requests. | ||||
| func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error { | ||||
| 	if !client.hubVerified && msg.Action != common.CheckFingerprint { | ||||
| 		return errors.New("hub not verified") | ||||
| 	} | ||||
| 	switch msg.Action { | ||||
| 	case common.GetData: | ||||
| 		return client.sendSystemData() | ||||
| 	case common.CheckFingerprint: | ||||
| 		return client.handleAuthChallenge(msg) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // sendSystemData gathers and sends current system statistics to the hub. | ||||
| func (client *WebSocketClient) sendSystemData() error { | ||||
| 	sysStats := client.agent.gatherStats(client.token) | ||||
| 	return client.sendMessage(sysStats) | ||||
| } | ||||
|  | ||||
| // sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub. | ||||
| func (client *WebSocketClient) sendMessage(data any) error { | ||||
| 	bytes, err := cbor.Marshal(data) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return client.Conn.WriteMessage(gws.OpcodeBinary, bytes) | ||||
| } | ||||
|  | ||||
| // getUserAgent returns one of two User-Agent strings based on current time. | ||||
| // This is used to avoid being blocked by Cloudflare or other anti-bot measures. | ||||
| func getUserAgent() string { | ||||
| 	const ( | ||||
| 		uaBase    = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" | ||||
| 		uaWindows = "Windows NT 11.0; Win64; x64" | ||||
| 		uaMac     = "Macintosh; Intel Mac OS X 14_0_0" | ||||
| 	) | ||||
| 	if time.Now().UnixNano()%2 == 0 { | ||||
| 		return fmt.Sprintf(uaBase, uaWindows) | ||||
| 	} | ||||
| 	return fmt.Sprintf(uaBase, uaMac) | ||||
| } | ||||
							
								
								
									
										561
									
								
								agent/client_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,561 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"crypto/ed25519" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/common" | ||||
|  | ||||
| 	"github.com/fxamacker/cbor/v2" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| // TestNewWebSocketClient tests WebSocket client creation | ||||
| func TestNewWebSocketClient(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	testCases := []struct { | ||||
| 		name        string | ||||
| 		hubURL      string | ||||
| 		token       string | ||||
| 		expectError bool | ||||
| 		errorMsg    string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:        "valid configuration", | ||||
| 			hubURL:      "http://localhost:8080", | ||||
| 			token:       "test-token-123", | ||||
| 			expectError: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "valid https URL", | ||||
| 			hubURL:      "https://hub.example.com", | ||||
| 			token:       "secure-token", | ||||
| 			expectError: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "missing hub URL", | ||||
| 			hubURL:      "", | ||||
| 			token:       "test-token", | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "HUB_URL environment variable not set", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "invalid URL", | ||||
| 			hubURL:      "ht\ttp://invalid", | ||||
| 			token:       "test-token", | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "invalid hub URL", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "missing token", | ||||
| 			hubURL:      "http://localhost:8080", | ||||
| 			token:       "", | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "must set TOKEN or TOKEN_FILE", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			// Set up environment | ||||
| 			if tc.hubURL != "" { | ||||
| 				os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL) | ||||
| 			} else { | ||||
| 				os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 			} | ||||
| 			if tc.token != "" { | ||||
| 				os.Setenv("BESZEL_AGENT_TOKEN", tc.token) | ||||
| 			} else { | ||||
| 				os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 			} | ||||
| 			defer func() { | ||||
| 				os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 				os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 			}() | ||||
|  | ||||
| 			client, err := newWebSocketClient(agent) | ||||
|  | ||||
| 			if tc.expectError { | ||||
| 				assert.Error(t, err) | ||||
| 				if err != nil && tc.errorMsg != "" { | ||||
| 					assert.Contains(t, err.Error(), tc.errorMsg) | ||||
| 				} | ||||
| 				assert.Nil(t, client) | ||||
| 			} else { | ||||
| 				require.NoError(t, err) | ||||
| 				assert.NotNil(t, client) | ||||
| 				assert.Equal(t, agent, client.agent) | ||||
| 				assert.Equal(t, tc.token, client.token) | ||||
| 				assert.Equal(t, tc.hubURL, client.hubURL.String()) | ||||
| 				assert.NotEmpty(t, client.fingerprint) | ||||
| 				assert.NotNil(t, client.hubRequest) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_GetOptions tests WebSocket client options configuration | ||||
| func TestWebSocketClient_GetOptions(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	testCases := []struct { | ||||
| 		name           string | ||||
| 		inputURL       string | ||||
| 		expectedScheme string | ||||
| 		expectedPath   string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:           "http to ws conversion", | ||||
| 			inputURL:       "http://localhost:8080", | ||||
| 			expectedScheme: "ws", | ||||
| 			expectedPath:   "/api/beszel/agent-connect", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "https to wss conversion", | ||||
| 			inputURL:       "https://hub.example.com", | ||||
| 			expectedScheme: "wss", | ||||
| 			expectedPath:   "/api/beszel/agent-connect", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "existing path preservation", | ||||
| 			inputURL:       "http://localhost:8080/custom/path", | ||||
| 			expectedScheme: "ws", | ||||
| 			expectedPath:   "/custom/path/api/beszel/agent-connect", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			// Set up environment | ||||
| 			os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL) | ||||
| 			os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 			defer func() { | ||||
| 				os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 				os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 			}() | ||||
|  | ||||
| 			client, err := newWebSocketClient(agent) | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			options := client.getOptions() | ||||
|  | ||||
| 			// Parse the WebSocket URL | ||||
| 			wsURL, err := url.Parse(options.Addr) | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			assert.Equal(t, tc.expectedScheme, wsURL.Scheme) | ||||
| 			assert.Equal(t, tc.expectedPath, wsURL.Path) | ||||
|  | ||||
| 			// Check headers | ||||
| 			assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token")) | ||||
| 			assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel")) | ||||
| 			assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0") | ||||
|  | ||||
| 			// Test options caching | ||||
| 			options2 := client.getOptions() | ||||
| 			assert.Same(t, options, options2, "Options should be cached") | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_VerifySignature tests signature verification | ||||
| func TestWebSocketClient_VerifySignature(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	// Generate test key pairs | ||||
| 	_, goodPrivKey, err := ed25519.GenerateKey(nil) | ||||
| 	require.NoError(t, err) | ||||
| 	goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey)) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	_, badPrivKey, err := ed25519.GenerateKey(nil) | ||||
| 	require.NoError(t, err) | ||||
| 	badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey)) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Set up environment | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	client, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	testCases := []struct { | ||||
| 		name        string | ||||
| 		keys        []ssh.PublicKey | ||||
| 		token       string | ||||
| 		signWith    ed25519.PrivateKey | ||||
| 		expectError bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:        "valid signature with correct key", | ||||
| 			keys:        []ssh.PublicKey{goodPubKey}, | ||||
| 			token:       "test-token", | ||||
| 			signWith:    goodPrivKey, | ||||
| 			expectError: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "invalid signature with wrong key", | ||||
| 			keys:        []ssh.PublicKey{goodPubKey}, | ||||
| 			token:       "test-token", | ||||
| 			signWith:    badPrivKey, | ||||
| 			expectError: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "valid signature with multiple keys", | ||||
| 			keys:        []ssh.PublicKey{badPubKey, goodPubKey}, | ||||
| 			token:       "test-token", | ||||
| 			signWith:    goodPrivKey, | ||||
| 			expectError: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "no valid keys", | ||||
| 			keys:        []ssh.PublicKey{badPubKey}, | ||||
| 			token:       "test-token", | ||||
| 			signWith:    goodPrivKey, | ||||
| 			expectError: true, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			// Set up agent with test keys | ||||
| 			agent.keys = tc.keys | ||||
| 			client.token = tc.token | ||||
|  | ||||
| 			// Create signature | ||||
| 			signature := ed25519.Sign(tc.signWith, []byte(tc.token)) | ||||
|  | ||||
| 			err := client.verifySignature(signature) | ||||
|  | ||||
| 			if tc.expectError { | ||||
| 				assert.Error(t, err) | ||||
| 				assert.Contains(t, err.Error(), "invalid signature") | ||||
| 			} else { | ||||
| 				assert.NoError(t, err) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic) | ||||
| func TestWebSocketClient_HandleHubRequest(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	// Set up environment | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	client, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	testCases := []struct { | ||||
| 		name        string | ||||
| 		action      common.WebSocketAction | ||||
| 		hubVerified bool | ||||
| 		expectError bool | ||||
| 		errorMsg    string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:        "CheckFingerprint without verification", | ||||
| 			action:      common.CheckFingerprint, | ||||
| 			hubVerified: false, | ||||
| 			expectError: false, // CheckFingerprint is allowed without verification | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "GetData without verification", | ||||
| 			action:      common.GetData, | ||||
| 			hubVerified: false, | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "hub not verified", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			client.hubVerified = tc.hubVerified | ||||
|  | ||||
| 			// Create minimal request | ||||
| 			hubRequest := &common.HubRequest[cbor.RawMessage]{ | ||||
| 				Action: tc.action, | ||||
| 				Data:   cbor.RawMessage{}, | ||||
| 			} | ||||
|  | ||||
| 			err := client.handleHubRequest(hubRequest) | ||||
|  | ||||
| 			if tc.expectError { | ||||
| 				assert.Error(t, err) | ||||
| 				if tc.errorMsg != "" { | ||||
| 					assert.Contains(t, err.Error(), tc.errorMsg) | ||||
| 				} | ||||
| 			} else { | ||||
| 				// For CheckFingerprint, we expect a decode error since we're not providing valid data, | ||||
| 				// but it shouldn't be the "hub not verified" error | ||||
| 				if err != nil && tc.errorMsg != "" { | ||||
| 					assert.NotContains(t, err.Error(), tc.errorMsg) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_GetUserAgent tests user agent generation | ||||
| func TestGetUserAgent(t *testing.T) { | ||||
| 	// Run multiple times to check both variants | ||||
| 	userAgents := make(map[string]bool) | ||||
|  | ||||
| 	for range 20 { | ||||
| 		ua := getUserAgent() | ||||
| 		userAgents[ua] = true | ||||
|  | ||||
| 		// Check that it's a valid Mozilla user agent | ||||
| 		assert.Contains(t, ua, "Mozilla/5.0") | ||||
| 		assert.Contains(t, ua, "AppleWebKit/537.36") | ||||
| 		assert.Contains(t, ua, "Chrome/124.0.0.0") | ||||
| 		assert.Contains(t, ua, "Safari/537.36") | ||||
|  | ||||
| 		// Should contain either Windows or Mac | ||||
| 		isWindows := strings.Contains(ua, "Windows NT 11.0") | ||||
| 		isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0") | ||||
| 		assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier") | ||||
| 	} | ||||
|  | ||||
| 	// With enough iterations, we should see both variants | ||||
| 	// though this might occasionally fail | ||||
| 	if len(userAgents) == 1 { | ||||
| 		t.Log("Note: Only one user agent variant was generated in this test run") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_Close tests connection closing | ||||
| func TestWebSocketClient_Close(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	// Set up environment | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	client, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Test closing with nil connection (should not panic) | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		client.Close() | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TestWebSocketClient_ConnectRateLimit tests connection rate limiting | ||||
| func TestWebSocketClient_ConnectRateLimit(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
|  | ||||
| 	// Set up environment | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	client, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Set recent connection attempt | ||||
| 	client.lastConnectAttempt = time.Now() | ||||
|  | ||||
| 	// Test that connection fails quickly due to rate limiting | ||||
| 	// This won't actually connect but should fail fast | ||||
| 	err = client.Connect() | ||||
| 	assert.Error(t, err, "Connection should fail but not hang") | ||||
| } | ||||
|  | ||||
| // TestGetToken tests the getToken function with various scenarios | ||||
| func TestGetToken(t *testing.T) { | ||||
| 	unsetEnvVars := func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 		os.Unsetenv("TOKEN") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN_FILE") | ||||
| 		os.Unsetenv("TOKEN_FILE") | ||||
| 	} | ||||
|  | ||||
| 	t.Run("token from TOKEN environment variable", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Set TOKEN env var | ||||
| 		expectedToken := "test-token-from-env" | ||||
| 		os.Setenv("TOKEN", expectedToken) | ||||
| 		defer os.Unsetenv("TOKEN") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, expectedToken, token) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Set BESZEL_AGENT_TOKEN env var (should take precedence) | ||||
| 		expectedToken := "test-token-from-beszel-env" | ||||
| 		os.Setenv("BESZEL_AGENT_TOKEN", expectedToken) | ||||
| 		defer os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, expectedToken, token) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("token from TOKEN_FILE", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Create a temporary token file | ||||
| 		expectedToken := "test-token-from-file" | ||||
| 		tokenFile, err := os.CreateTemp("", "token-test-*.txt") | ||||
| 		require.NoError(t, err) | ||||
| 		defer os.Remove(tokenFile.Name()) | ||||
|  | ||||
| 		_, err = tokenFile.WriteString(expectedToken) | ||||
| 		require.NoError(t, err) | ||||
| 		tokenFile.Close() | ||||
|  | ||||
| 		// Set TOKEN_FILE env var | ||||
| 		os.Setenv("TOKEN_FILE", tokenFile.Name()) | ||||
| 		defer os.Unsetenv("TOKEN_FILE") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, expectedToken, token) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Create a temporary token file | ||||
| 		expectedToken := "test-token-from-beszel-file" | ||||
| 		tokenFile, err := os.CreateTemp("", "token-test-*.txt") | ||||
| 		require.NoError(t, err) | ||||
| 		defer os.Remove(tokenFile.Name()) | ||||
|  | ||||
| 		_, err = tokenFile.WriteString(expectedToken) | ||||
| 		require.NoError(t, err) | ||||
| 		tokenFile.Close() | ||||
|  | ||||
| 		// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence) | ||||
| 		os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name()) | ||||
| 		defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, expectedToken, token) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Create a temporary token file | ||||
| 		fileToken := "token-from-file" | ||||
| 		tokenFile, err := os.CreateTemp("", "token-test-*.txt") | ||||
| 		require.NoError(t, err) | ||||
| 		defer os.Remove(tokenFile.Name()) | ||||
|  | ||||
| 		_, err = tokenFile.WriteString(fileToken) | ||||
| 		require.NoError(t, err) | ||||
| 		tokenFile.Close() | ||||
|  | ||||
| 		// Set both TOKEN and TOKEN_FILE | ||||
| 		envToken := "token-from-env" | ||||
| 		os.Setenv("TOKEN", envToken) | ||||
| 		os.Setenv("TOKEN_FILE", tokenFile.Name()) | ||||
| 		defer func() { | ||||
| 			os.Unsetenv("TOKEN") | ||||
| 			os.Unsetenv("TOKEN_FILE") | ||||
| 		}() | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.Error(t, err) | ||||
| 		assert.Equal(t, "", token) | ||||
| 		assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Set TOKEN_FILE to a non-existent file | ||||
| 		os.Setenv("TOKEN_FILE", "/non/existent/file.txt") | ||||
| 		defer os.Unsetenv("TOKEN_FILE") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.Error(t, err) | ||||
| 		assert.Equal(t, "", token) | ||||
| 		assert.Contains(t, err.Error(), "no such file or directory") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handles empty token file", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		// Create an empty token file | ||||
| 		tokenFile, err := os.CreateTemp("", "token-test-*.txt") | ||||
| 		require.NoError(t, err) | ||||
| 		defer os.Remove(tokenFile.Name()) | ||||
| 		tokenFile.Close() | ||||
|  | ||||
| 		// Set TOKEN_FILE env var | ||||
| 		os.Setenv("TOKEN_FILE", tokenFile.Name()) | ||||
| 		defer os.Unsetenv("TOKEN_FILE") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, "", token, "Empty file should return empty string") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) { | ||||
| 		unsetEnvVars() | ||||
|  | ||||
| 		tokenWithWhitespace := "  test-token-with-whitespace  \n\t" | ||||
| 		expectedToken := "test-token-with-whitespace" | ||||
| 		tokenFile, err := os.CreateTemp("", "token-test-*.txt") | ||||
| 		require.NoError(t, err) | ||||
| 		defer os.Remove(tokenFile.Name()) | ||||
|  | ||||
| 		_, err = tokenFile.WriteString(tokenWithWhitespace) | ||||
| 		require.NoError(t, err) | ||||
| 		tokenFile.Close() | ||||
|  | ||||
| 		os.Setenv("TOKEN_FILE", tokenFile.Name()) | ||||
| 		defer os.Unsetenv("TOKEN_FILE") | ||||
|  | ||||
| 		token, err := getToken() | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content") | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										221
									
								
								agent/connection_manager.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,221 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/agent/health" | ||||
| ) | ||||
|  | ||||
| // ConnectionManager manages the connection state and events for the agent. | ||||
| // It handles both WebSocket and SSH connections, automatically switching between | ||||
| // them based on availability and managing reconnection attempts. | ||||
| type ConnectionManager struct { | ||||
| 	agent         *Agent               // Reference to the parent agent | ||||
| 	State         ConnectionState      // Current connection state | ||||
| 	eventChan     chan ConnectionEvent // Channel for connection events | ||||
| 	wsClient      *WebSocketClient     // WebSocket client for hub communication | ||||
| 	serverOptions ServerOptions        // Configuration for SSH server | ||||
| 	wsTicker      *time.Ticker         // Ticker for WebSocket connection attempts | ||||
| 	isConnecting  bool                 // Prevents multiple simultaneous reconnection attempts | ||||
| } | ||||
|  | ||||
| // ConnectionState represents the current connection state of the agent. | ||||
| type ConnectionState uint8 | ||||
|  | ||||
| // ConnectionEvent represents connection-related events that can occur. | ||||
| type ConnectionEvent uint8 | ||||
|  | ||||
| // Connection states | ||||
| const ( | ||||
| 	Disconnected       ConnectionState = iota // No active connection | ||||
| 	WebSocketConnected                        // Connected via WebSocket | ||||
| 	SSHConnected                              // Connected via SSH | ||||
| ) | ||||
|  | ||||
| // Connection events | ||||
| const ( | ||||
| 	WebSocketConnect    ConnectionEvent = iota // WebSocket connection established | ||||
| 	WebSocketDisconnect                        // WebSocket connection lost | ||||
| 	SSHConnect                                 // SSH connection established | ||||
| 	SSHDisconnect                              // SSH connection lost | ||||
| ) | ||||
|  | ||||
| const wsTickerInterval = 10 * time.Second | ||||
|  | ||||
| // newConnectionManager creates a new connection manager for the given agent. | ||||
| func newConnectionManager(agent *Agent) *ConnectionManager { | ||||
| 	cm := &ConnectionManager{ | ||||
| 		agent: agent, | ||||
| 		State: Disconnected, | ||||
| 	} | ||||
| 	return cm | ||||
| } | ||||
|  | ||||
| // startWsTicker starts or resets the WebSocket connection attempt ticker. | ||||
| func (c *ConnectionManager) startWsTicker() { | ||||
| 	if c.wsTicker == nil { | ||||
| 		c.wsTicker = time.NewTicker(wsTickerInterval) | ||||
| 	} else { | ||||
| 		c.wsTicker.Reset(wsTickerInterval) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // stopWsTicker stops the WebSocket connection attempt ticker. | ||||
| func (c *ConnectionManager) stopWsTicker() { | ||||
| 	if c.wsTicker != nil { | ||||
| 		c.wsTicker.Stop() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Start begins connection attempts and enters the main event loop. | ||||
| // It handles connection events, periodic health updates, and graceful shutdown. | ||||
| func (c *ConnectionManager) Start(serverOptions ServerOptions) error { | ||||
| 	if c.eventChan != nil { | ||||
| 		return errors.New("already started") | ||||
| 	} | ||||
|  | ||||
| 	wsClient, err := newWebSocketClient(c.agent) | ||||
| 	if err != nil { | ||||
| 		slog.Warn("Error creating WebSocket client", "err", err) | ||||
| 	} | ||||
| 	c.wsClient = wsClient | ||||
|  | ||||
| 	c.serverOptions = serverOptions | ||||
| 	c.eventChan = make(chan ConnectionEvent, 1) | ||||
|  | ||||
| 	// signal handling for shutdown | ||||
| 	sigChan := make(chan os.Signal, 1) | ||||
| 	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | ||||
|  | ||||
| 	c.startWsTicker() | ||||
| 	c.connect() | ||||
|  | ||||
| 	// update health status immediately and every 90 seconds | ||||
| 	_ = health.Update() | ||||
| 	healthTicker := time.Tick(90 * time.Second) | ||||
|  | ||||
| 	for { | ||||
| 		select { | ||||
| 		case connectionEvent := <-c.eventChan: | ||||
| 			c.handleEvent(connectionEvent) | ||||
| 		case <-c.wsTicker.C: | ||||
| 			_ = c.startWebSocketConnection() | ||||
| 		case <-healthTicker: | ||||
| 			_ = health.Update() | ||||
| 		case <-sigChan: | ||||
| 			slog.Info("Shutting down") | ||||
| 			_ = c.agent.StopServer() | ||||
| 			c.closeWebSocket() | ||||
| 			return health.CleanUp() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // handleEvent processes connection events and updates the connection state accordingly. | ||||
| func (c *ConnectionManager) handleEvent(event ConnectionEvent) { | ||||
| 	switch event { | ||||
| 	case WebSocketConnect: | ||||
| 		c.handleStateChange(WebSocketConnected) | ||||
| 	case SSHConnect: | ||||
| 		c.handleStateChange(SSHConnected) | ||||
| 	case WebSocketDisconnect: | ||||
| 		if c.State == WebSocketConnected { | ||||
| 			c.handleStateChange(Disconnected) | ||||
| 		} | ||||
| 	case SSHDisconnect: | ||||
| 		if c.State == SSHConnected { | ||||
| 			c.handleStateChange(Disconnected) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // handleStateChange updates the connection state and performs necessary actions | ||||
| // based on the new state, including stopping services and initiating reconnections. | ||||
| func (c *ConnectionManager) handleStateChange(newState ConnectionState) { | ||||
| 	if c.State == newState { | ||||
| 		return | ||||
| 	} | ||||
| 	c.State = newState | ||||
| 	switch newState { | ||||
| 	case WebSocketConnected: | ||||
| 		slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host) | ||||
| 		c.stopWsTicker() | ||||
| 		_ = c.agent.StopServer() | ||||
| 		c.isConnecting = false | ||||
| 	case SSHConnected: | ||||
| 		// stop new ws connection attempts | ||||
| 		slog.Info("SSH connection established") | ||||
| 		c.stopWsTicker() | ||||
| 		c.isConnecting = false | ||||
| 	case Disconnected: | ||||
| 		if c.isConnecting { | ||||
| 			// Already handling reconnection, avoid duplicate attempts | ||||
| 			return | ||||
| 		} | ||||
| 		c.isConnecting = true | ||||
| 		slog.Warn("Disconnected from hub") | ||||
| 		// make sure old ws connection is closed | ||||
| 		c.closeWebSocket() | ||||
| 		// reconnect | ||||
| 		go c.connect() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // connect handles the connection logic with proper delays and priority. | ||||
| // It attempts WebSocket connection first, falling back to SSH server if needed. | ||||
| func (c *ConnectionManager) connect() { | ||||
| 	c.isConnecting = true | ||||
| 	defer func() { | ||||
| 		c.isConnecting = false | ||||
| 	}() | ||||
|  | ||||
| 	if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second { | ||||
| 		time.Sleep(5 * time.Second) | ||||
| 	} | ||||
|  | ||||
| 	// Try WebSocket first, if it fails, start SSH server | ||||
| 	err := c.startWebSocketConnection() | ||||
| 	if err != nil && c.State == Disconnected { | ||||
| 		c.startSSHServer() | ||||
| 		c.startWsTicker() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // startWebSocketConnection attempts to establish a WebSocket connection to the hub. | ||||
| func (c *ConnectionManager) startWebSocketConnection() error { | ||||
| 	if c.State != Disconnected { | ||||
| 		return errors.New("already connected") | ||||
| 	} | ||||
| 	if c.wsClient == nil { | ||||
| 		return errors.New("WebSocket client not initialized") | ||||
| 	} | ||||
| 	if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second { | ||||
| 		return errors.New("already connecting") | ||||
| 	} | ||||
|  | ||||
| 	err := c.wsClient.Connect() | ||||
| 	if err != nil { | ||||
| 		slog.Warn("WebSocket connection failed", "err", err) | ||||
| 		c.closeWebSocket() | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // startSSHServer starts the SSH server if the agent is currently disconnected. | ||||
| func (c *ConnectionManager) startSSHServer() { | ||||
| 	if c.State == Disconnected { | ||||
| 		go c.agent.StartServer(c.serverOptions) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // closeWebSocket closes the WebSocket connection if it exists. | ||||
| func (c *ConnectionManager) closeWebSocket() { | ||||
| 	if c.wsClient != nil { | ||||
| 		c.wsClient.Close() | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										315
									
								
								agent/connection_manager_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,315 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"crypto/ed25519" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| func createTestAgent(t *testing.T) *Agent { | ||||
| 	dataDir := t.TempDir() | ||||
| 	agent, err := NewAgent(dataDir) | ||||
| 	require.NoError(t, err) | ||||
| 	return agent | ||||
| } | ||||
|  | ||||
| func createTestServerOptions(t *testing.T) ServerOptions { | ||||
| 	// Generate test key pair | ||||
| 	_, privKey, err := ed25519.GenerateKey(nil) | ||||
| 	require.NoError(t, err) | ||||
| 	sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey)) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Find available port | ||||
| 	listener, err := net.Listen("tcp", "127.0.0.1:0") | ||||
| 	require.NoError(t, err) | ||||
| 	port := listener.Addr().(*net.TCPAddr).Port | ||||
| 	listener.Close() | ||||
|  | ||||
| 	return ServerOptions{ | ||||
| 		Network: "tcp", | ||||
| 		Addr:    fmt.Sprintf("127.0.0.1:%d", port), | ||||
| 		Keys:    []ssh.PublicKey{sshPubKey}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_NewConnectionManager tests connection manager creation | ||||
| func TestConnectionManager_NewConnectionManager(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := newConnectionManager(agent) | ||||
|  | ||||
| 	assert.NotNil(t, cm, "Connection manager should not be nil") | ||||
| 	assert.Equal(t, agent, cm.agent, "Agent reference should be set") | ||||
| 	assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected") | ||||
| 	assert.Nil(t, cm.eventChan, "Event channel should be nil initially") | ||||
| 	assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially") | ||||
| 	assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially") | ||||
| 	assert.False(t, cm.isConnecting, "isConnecting should be false initially") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_StateTransitions tests basic state transitions | ||||
| func TestConnectionManager_StateTransitions(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
| 	initialState := cm.State | ||||
| 	cm.wsClient = &WebSocketClient{ | ||||
| 		hubURL: &url.URL{ | ||||
| 			Host: "localhost:8080", | ||||
| 		}, | ||||
| 	} | ||||
| 	assert.NotNil(t, cm, "Connection manager should not be nil") | ||||
| 	assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected") | ||||
|  | ||||
| 	// Test state transitions | ||||
| 	cm.handleStateChange(WebSocketConnected) | ||||
| 	assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected") | ||||
|  | ||||
| 	cm.handleStateChange(SSHConnected) | ||||
| 	assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected") | ||||
|  | ||||
| 	cm.handleStateChange(Disconnected) | ||||
| 	assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected") | ||||
|  | ||||
| 	// Test that same state doesn't trigger changes | ||||
| 	cm.State = WebSocketConnected | ||||
| 	cm.handleStateChange(WebSocketConnected) | ||||
| 	assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_EventHandling tests event handling logic | ||||
| func TestConnectionManager_EventHandling(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
| 	cm.wsClient = &WebSocketClient{ | ||||
| 		hubURL: &url.URL{ | ||||
| 			Host: "localhost:8080", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	testCases := []struct { | ||||
| 		name          string | ||||
| 		initialState  ConnectionState | ||||
| 		event         ConnectionEvent | ||||
| 		expectedState ConnectionState | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:          "WebSocket connect from disconnected", | ||||
| 			initialState:  Disconnected, | ||||
| 			event:         WebSocketConnect, | ||||
| 			expectedState: WebSocketConnected, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "SSH connect from disconnected", | ||||
| 			initialState:  Disconnected, | ||||
| 			event:         SSHConnect, | ||||
| 			expectedState: SSHConnected, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "WebSocket disconnect from connected", | ||||
| 			initialState:  WebSocketConnected, | ||||
| 			event:         WebSocketDisconnect, | ||||
| 			expectedState: Disconnected, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "SSH disconnect from connected", | ||||
| 			initialState:  SSHConnected, | ||||
| 			event:         SSHDisconnect, | ||||
| 			expectedState: Disconnected, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "WebSocket disconnect from SSH connected (no change)", | ||||
| 			initialState:  SSHConnected, | ||||
| 			event:         WebSocketDisconnect, | ||||
| 			expectedState: SSHConnected, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "SSH disconnect from WebSocket connected (no change)", | ||||
| 			initialState:  WebSocketConnected, | ||||
| 			event:         SSHDisconnect, | ||||
| 			expectedState: WebSocketConnected, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			cm.State = tc.initialState | ||||
| 			cm.handleEvent(tc.event) | ||||
| 			assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event") | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_TickerManagement tests WebSocket ticker management | ||||
| func TestConnectionManager_TickerManagement(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
|  | ||||
| 	// Test starting ticker | ||||
| 	cm.startWsTicker() | ||||
| 	assert.NotNil(t, cm.wsTicker, "Ticker should be created") | ||||
|  | ||||
| 	// Test stopping ticker (should not panic) | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cm.stopWsTicker() | ||||
| 	}, "Stopping ticker should not panic") | ||||
|  | ||||
| 	// Test stopping nil ticker (should not panic) | ||||
| 	cm.wsTicker = nil | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cm.stopWsTicker() | ||||
| 	}, "Stopping nil ticker should not panic") | ||||
|  | ||||
| 	// Test restarting ticker | ||||
| 	cm.startWsTicker() | ||||
| 	assert.NotNil(t, cm.wsTicker, "Ticker should be recreated") | ||||
|  | ||||
| 	// Test resetting existing ticker | ||||
| 	firstTicker := cm.wsTicker | ||||
| 	cm.startWsTicker() | ||||
| 	assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused") | ||||
|  | ||||
| 	cm.stopWsTicker() | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic | ||||
| func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) { | ||||
| 	if testing.Short() { | ||||
| 		t.Skip("Skipping WebSocket connection test in short mode") | ||||
| 	} | ||||
|  | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
|  | ||||
| 	// Test WebSocket connection without proper environment | ||||
| 	err := cm.startWebSocketConnection() | ||||
| 	assert.Error(t, err, "WebSocket connection should fail without proper environment") | ||||
| 	assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection") | ||||
|  | ||||
| 	// Test with invalid URL | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	// Test with missing token | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") | ||||
| 	os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
|  | ||||
| 	_, err2 := newWebSocketClient(agent) | ||||
| 	assert.Error(t, err2, "WebSocket client creation should fail without token") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_ReconnectionLogic tests reconnection prevention logic | ||||
| func TestConnectionManager_ReconnectionLogic(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
| 	cm.eventChan = make(chan ConnectionEvent, 1) | ||||
|  | ||||
| 	// Test that isConnecting flag prevents duplicate reconnection attempts | ||||
| 	// Start from connected state, then simulate disconnect | ||||
| 	cm.State = WebSocketConnected | ||||
| 	cm.isConnecting = false | ||||
|  | ||||
| 	// First disconnect should trigger reconnection logic | ||||
| 	cm.handleStateChange(Disconnected) | ||||
| 	assert.Equal(t, Disconnected, cm.State, "Should change to disconnected") | ||||
| 	assert.True(t, cm.isConnecting, "Should set isConnecting flag") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_ConnectWithRateLimit tests connection rate limiting | ||||
| func TestConnectionManager_ConnectWithRateLimit(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
|  | ||||
| 	// Set up environment for WebSocket client creation | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	// Create WebSocket client | ||||
| 	wsClient, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
| 	cm.wsClient = wsClient | ||||
|  | ||||
| 	// Set recent connection attempt | ||||
| 	cm.wsClient.lastConnectAttempt = time.Now() | ||||
|  | ||||
| 	// Test that connection is rate limited | ||||
| 	err = cm.startWebSocketConnection() | ||||
| 	assert.Error(t, err, "Should error due to rate limiting") | ||||
| 	assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting") | ||||
|  | ||||
| 	// Test connection after rate limit expires | ||||
| 	cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second) | ||||
| 	err = cm.startWebSocketConnection() | ||||
| 	// This will fail due to no actual server, but should not be rate limited | ||||
| 	assert.Error(t, err, "Connection should fail but not due to rate limiting") | ||||
| 	assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_StartWithInvalidConfig tests starting with invalid configuration | ||||
| func TestConnectionManager_StartWithInvalidConfig(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
| 	serverOptions := createTestServerOptions(t) | ||||
|  | ||||
| 	// Test starting when already started | ||||
| 	cm.eventChan = make(chan ConnectionEvent, 5) | ||||
| 	err := cm.Start(serverOptions) | ||||
| 	assert.Error(t, err, "Should error when starting already started connection manager") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_CloseWebSocket tests WebSocket closing | ||||
| func TestConnectionManager_CloseWebSocket(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
|  | ||||
| 	// Test closing when no WebSocket client exists | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cm.closeWebSocket() | ||||
| 	}, "Should not panic when closing nil WebSocket client") | ||||
|  | ||||
| 	// Set up environment and create WebSocket client | ||||
| 	os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") | ||||
| 	os.Setenv("BESZEL_AGENT_TOKEN", "test-token") | ||||
| 	defer func() { | ||||
| 		os.Unsetenv("BESZEL_AGENT_HUB_URL") | ||||
| 		os.Unsetenv("BESZEL_AGENT_TOKEN") | ||||
| 	}() | ||||
|  | ||||
| 	wsClient, err := newWebSocketClient(agent) | ||||
| 	require.NoError(t, err) | ||||
| 	cm.wsClient = wsClient | ||||
|  | ||||
| 	// Test closing when WebSocket client exists | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cm.closeWebSocket() | ||||
| 	}, "Should not panic when closing WebSocket client") | ||||
| } | ||||
|  | ||||
| // TestConnectionManager_ConnectFlow tests the connect method | ||||
| func TestConnectionManager_ConnectFlow(t *testing.T) { | ||||
| 	agent := createTestAgent(t) | ||||
| 	cm := agent.connectionManager | ||||
|  | ||||
| 	// Test connect without WebSocket client | ||||
| 	assert.NotPanics(t, func() { | ||||
| 		cm.connect() | ||||
| 	}, "Connect should not panic without WebSocket client") | ||||
| } | ||||
							
								
								
									
										117
									
								
								agent/data_dir.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,117 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| ) | ||||
|  | ||||
| // getDataDir returns the path to the data directory for the agent and an error | ||||
| // if the directory is not valid. Attempts to find the optimal data directory if | ||||
| // no data directories are provided. | ||||
| func getDataDir(dataDirs ...string) (string, error) { | ||||
| 	if len(dataDirs) > 0 { | ||||
| 		return testDataDirs(dataDirs) | ||||
| 	} | ||||
|  | ||||
| 	dataDir, _ := GetEnv("DATA_DIR") | ||||
| 	if dataDir != "" { | ||||
| 		dataDirs = append(dataDirs, dataDir) | ||||
| 	} | ||||
|  | ||||
| 	if runtime.GOOS == "windows" { | ||||
| 		dataDirs = append(dataDirs, | ||||
| 			filepath.Join(os.Getenv("APPDATA"), "beszel-agent"), | ||||
| 			filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"), | ||||
| 		) | ||||
| 	} else { | ||||
| 		dataDirs = append(dataDirs, "/var/lib/beszel-agent") | ||||
| 		if homeDir, err := os.UserHomeDir(); err == nil { | ||||
| 			dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel")) | ||||
| 		} | ||||
| 	} | ||||
| 	return testDataDirs(dataDirs) | ||||
| } | ||||
|  | ||||
| func testDataDirs(paths []string) (string, error) { | ||||
| 	// first check if the directory exists and is writable | ||||
| 	for _, path := range paths { | ||||
| 		if valid, _ := isValidDataDir(path, false); valid { | ||||
| 			return path, nil | ||||
| 		} | ||||
| 	} | ||||
| 	// if the directory doesn't exist, try to create it | ||||
| 	for _, path := range paths { | ||||
| 		exists, _ := directoryExists(path) | ||||
| 		if exists { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if err := os.MkdirAll(path, 0755); err != nil { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Verify the created directory is actually writable | ||||
| 		writable, _ := directoryIsWritable(path) | ||||
| 		if !writable { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		return path, nil | ||||
| 	} | ||||
|  | ||||
| 	return "", errors.New("data directory not found") | ||||
| } | ||||
|  | ||||
| func isValidDataDir(path string, createIfNotExists bool) (bool, error) { | ||||
| 	exists, err := directoryExists(path) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	if !exists { | ||||
| 		if !createIfNotExists { | ||||
| 			return false, nil | ||||
| 		} | ||||
| 		if err = os.MkdirAll(path, 0755); err != nil { | ||||
| 			return false, err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Always check if the directory is writable | ||||
| 	writable, err := directoryIsWritable(path) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
| 	return writable, nil | ||||
| } | ||||
|  | ||||
| // directoryExists checks if a directory exists | ||||
| func directoryExists(path string) (bool, error) { | ||||
| 	// Check if directory exists | ||||
| 	stat, err := os.Stat(path) | ||||
| 	if err != nil { | ||||
| 		if os.IsNotExist(err) { | ||||
| 			return false, nil | ||||
| 		} | ||||
| 		return false, err | ||||
| 	} | ||||
| 	if !stat.IsDir() { | ||||
| 		return false, fmt.Errorf("%s is not a directory", path) | ||||
| 	} | ||||
| 	return true, nil | ||||
| } | ||||
|  | ||||
| // directoryIsWritable tests if a directory is writable by creating and removing a temporary file | ||||
| func directoryIsWritable(path string) (bool, error) { | ||||
| 	testFile := filepath.Join(path, ".write-test") | ||||
| 	file, err := os.Create(testFile) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
| 	defer file.Close() | ||||
| 	defer os.Remove(testFile) | ||||
| 	return true, nil | ||||
| } | ||||
							
								
								
									
										263
									
								
								agent/data_dir_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,263 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestGetDataDir(t *testing.T) { | ||||
| 	// Test with explicit dataDir parameter | ||||
| 	t.Run("explicit data dir", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		result, err := getDataDir(tempDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, tempDir, result) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with explicit non-existent dataDir that can be created | ||||
| 	t.Run("explicit data dir - create new", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		newDir := filepath.Join(tempDir, "new-data-dir") | ||||
| 		result, err := getDataDir(newDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, newDir, result) | ||||
|  | ||||
| 		// Verify directory was created | ||||
| 		stat, err := os.Stat(newDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, stat.IsDir()) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with DATA_DIR environment variable | ||||
| 	t.Run("DATA_DIR environment variable", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
|  | ||||
| 		// Set environment variable | ||||
| 		oldValue := os.Getenv("DATA_DIR") | ||||
| 		defer func() { | ||||
| 			if oldValue == "" { | ||||
| 				os.Unsetenv("BESZEL_AGENT_DATA_DIR") | ||||
| 			} else { | ||||
| 				os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue) | ||||
| 			} | ||||
| 		}() | ||||
|  | ||||
| 		os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir) | ||||
|  | ||||
| 		result, err := getDataDir() | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, tempDir, result) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with invalid explicit dataDir | ||||
| 	t.Run("invalid explicit data dir", func(t *testing.T) { | ||||
| 		invalidPath := "/invalid/path/that/cannot/be/created" | ||||
| 		_, err := getDataDir(invalidPath) | ||||
| 		assert.Error(t, err) | ||||
| 	}) | ||||
|  | ||||
| 	// Test fallback behavior (empty dataDir, no env var) | ||||
| 	t.Run("fallback to default directories", func(t *testing.T) { | ||||
| 		// Clear DATA_DIR environment variable | ||||
| 		oldValue := os.Getenv("DATA_DIR") | ||||
| 		defer func() { | ||||
| 			if oldValue == "" { | ||||
| 				os.Unsetenv("DATA_DIR") | ||||
| 			} else { | ||||
| 				os.Setenv("DATA_DIR", oldValue) | ||||
| 			} | ||||
| 		}() | ||||
| 		os.Unsetenv("DATA_DIR") | ||||
|  | ||||
| 		// This will try platform-specific defaults, which may or may not work | ||||
| 		// We're mainly testing that it doesn't panic and returns some result | ||||
| 		result, err := getDataDir() | ||||
| 		// We don't assert success/failure here since it depends on system permissions | ||||
| 		// Just verify we get a string result if no error | ||||
| 		if err == nil { | ||||
| 			assert.NotEmpty(t, result) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestTestDataDirs(t *testing.T) { | ||||
| 	// Test with existing valid directory | ||||
| 	t.Run("existing valid directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		result, err := testDataDirs([]string{tempDir}) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, tempDir, result) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with multiple directories, first one valid | ||||
| 	t.Run("multiple dirs - first valid", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		invalidDir := "/invalid/path" | ||||
| 		result, err := testDataDirs([]string{tempDir, invalidDir}) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, tempDir, result) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with multiple directories, second one valid | ||||
| 	t.Run("multiple dirs - second valid", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		invalidDir := "/invalid/path" | ||||
| 		result, err := testDataDirs([]string{invalidDir, tempDir}) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, tempDir, result) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-existing directory that can be created | ||||
| 	t.Run("create new directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		newDir := filepath.Join(tempDir, "new-dir") | ||||
| 		result, err := testDataDirs([]string{newDir}) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.Equal(t, newDir, result) | ||||
|  | ||||
| 		// Verify directory was created | ||||
| 		stat, err := os.Stat(newDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, stat.IsDir()) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with no valid directories | ||||
| 	t.Run("no valid directories", func(t *testing.T) { | ||||
| 		invalidPaths := []string{"/invalid/path1", "/invalid/path2"} | ||||
| 		_, err := testDataDirs(invalidPaths) | ||||
| 		assert.Error(t, err) | ||||
| 		assert.Contains(t, err.Error(), "data directory not found") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestIsValidDataDir(t *testing.T) { | ||||
| 	// Test with existing directory | ||||
| 	t.Run("existing directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		valid, err := isValidDataDir(tempDir, false) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, valid) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-existing directory, createIfNotExists=false | ||||
| 	t.Run("non-existing dir - no create", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		nonExistentDir := filepath.Join(tempDir, "does-not-exist") | ||||
| 		valid, err := isValidDataDir(nonExistentDir, false) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.False(t, valid) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-existing directory, createIfNotExists=true | ||||
| 	t.Run("non-existing dir - create", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		newDir := filepath.Join(tempDir, "new-dir") | ||||
| 		valid, err := isValidDataDir(newDir, true) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, valid) | ||||
|  | ||||
| 		// Verify directory was created | ||||
| 		stat, err := os.Stat(newDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, stat.IsDir()) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with file instead of directory | ||||
| 	t.Run("file instead of directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		tempFile := filepath.Join(tempDir, "testfile") | ||||
| 		err := os.WriteFile(tempFile, []byte("test"), 0644) | ||||
| 		require.NoError(t, err) | ||||
|  | ||||
| 		valid, err := isValidDataDir(tempFile, false) | ||||
| 		assert.Error(t, err) | ||||
| 		assert.False(t, valid) | ||||
| 		assert.Contains(t, err.Error(), "is not a directory") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestDirectoryExists(t *testing.T) { | ||||
| 	// Test with existing directory | ||||
| 	t.Run("existing directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		exists, err := directoryExists(tempDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, exists) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-existing directory | ||||
| 	t.Run("non-existing directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		nonExistentDir := filepath.Join(tempDir, "does-not-exist") | ||||
| 		exists, err := directoryExists(nonExistentDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.False(t, exists) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with file instead of directory | ||||
| 	t.Run("file instead of directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		tempFile := filepath.Join(tempDir, "testfile") | ||||
| 		err := os.WriteFile(tempFile, []byte("test"), 0644) | ||||
| 		require.NoError(t, err) | ||||
|  | ||||
| 		exists, err := directoryExists(tempFile) | ||||
| 		assert.Error(t, err) | ||||
| 		assert.False(t, exists) | ||||
| 		assert.Contains(t, err.Error(), "is not a directory") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestDirectoryIsWritable(t *testing.T) { | ||||
| 	// Test with writable directory | ||||
| 	t.Run("writable directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		writable, err := directoryIsWritable(tempDir) | ||||
| 		require.NoError(t, err) | ||||
| 		assert.True(t, writable) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-existing directory | ||||
| 	t.Run("non-existing directory", func(t *testing.T) { | ||||
| 		tempDir := t.TempDir() | ||||
| 		nonExistentDir := filepath.Join(tempDir, "does-not-exist") | ||||
| 		writable, err := directoryIsWritable(nonExistentDir) | ||||
| 		assert.Error(t, err) | ||||
| 		assert.False(t, writable) | ||||
| 	}) | ||||
|  | ||||
| 	// Test with non-writable directory (Unix-like systems only) | ||||
| 	t.Run("non-writable directory", func(t *testing.T) { | ||||
| 		if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { | ||||
| 			t.Skip("Skipping non-writable directory test on", runtime.GOOS) | ||||
| 		} | ||||
|  | ||||
| 		tempDir := t.TempDir() | ||||
| 		readOnlyDir := filepath.Join(tempDir, "readonly") | ||||
|  | ||||
| 		// Create the directory | ||||
| 		err := os.Mkdir(readOnlyDir, 0755) | ||||
| 		require.NoError(t, err) | ||||
|  | ||||
| 		// Make it read-only | ||||
| 		err = os.Chmod(readOnlyDir, 0444) | ||||
| 		require.NoError(t, err) | ||||
|  | ||||
| 		// Restore permissions after test for cleanup | ||||
| 		defer func() { | ||||
| 			os.Chmod(readOnlyDir, 0755) | ||||
| 		}() | ||||
|  | ||||
| 		writable, err := directoryIsWritable(readOnlyDir) | ||||
| 		assert.Error(t, err) | ||||
| 		assert.False(t, writable) | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										191
									
								
								agent/disk.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,191 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/disk" | ||||
| ) | ||||
|  | ||||
| // Sets up the filesystems to monitor for disk usage and I/O. | ||||
| func (a *Agent) initializeDiskInfo() { | ||||
| 	filesystem, _ := GetEnv("FILESYSTEM") | ||||
| 	efPath := "/extra-filesystems" | ||||
| 	hasRoot := false | ||||
|  | ||||
| 	partitions, err := disk.Partitions(false) | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error getting disk partitions", "err", err) | ||||
| 	} | ||||
| 	slog.Debug("Disk", "partitions", partitions) | ||||
|  | ||||
| 	// ioContext := context.WithValue(a.sensorsContext, | ||||
| 	// 	common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"}, | ||||
| 	// ) | ||||
| 	// diskIoCounters, err := disk.IOCountersWithContext(ioContext) | ||||
|  | ||||
| 	diskIoCounters, err := disk.IOCounters() | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error getting diskstats", "err", err) | ||||
| 	} | ||||
| 	slog.Debug("Disk I/O", "diskstats", diskIoCounters) | ||||
|  | ||||
| 	// Helper function to add a filesystem to fsStats if it doesn't exist | ||||
| 	addFsStat := func(device, mountpoint string, root bool) { | ||||
| 		var key string | ||||
| 		if runtime.GOOS == "windows" { | ||||
| 			key = device | ||||
| 		} else { | ||||
| 			key = filepath.Base(device) | ||||
| 		} | ||||
| 		var ioMatch bool | ||||
| 		if _, exists := a.fsStats[key]; !exists { | ||||
| 			if root { | ||||
| 				slog.Info("Detected root device", "name", key) | ||||
| 				// Check if root device is in /proc/diskstats, use fallback if not | ||||
| 				if _, ioMatch = diskIoCounters[key]; !ioMatch { | ||||
| 					key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats) | ||||
| 					if !ioMatch { | ||||
| 						slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key) | ||||
| 					} | ||||
| 				} | ||||
| 			} else { | ||||
| 				// Check if non-root has diskstats and fall back to folder name if not | ||||
| 				// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats. | ||||
| 				// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1 | ||||
| 				if _, ioMatch = diskIoCounters[key]; !ioMatch { | ||||
| 					efBase := filepath.Base(mountpoint) | ||||
| 					if _, ioMatch = diskIoCounters[efBase]; ioMatch { | ||||
| 						key = efBase | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 			a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Use FILESYSTEM env var to find root filesystem | ||||
| 	if filesystem != "" { | ||||
| 		for _, p := range partitions { | ||||
| 			if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem { | ||||
| 				addFsStat(p.Device, p.Mountpoint, true) | ||||
| 				hasRoot = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if !hasRoot { | ||||
| 			slog.Warn("Partition details not found", "filesystem", filesystem) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Add EXTRA_FILESYSTEMS env var values to fsStats | ||||
| 	if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists { | ||||
| 		for _, fs := range strings.Split(extraFilesystems, ",") { | ||||
| 			found := false | ||||
| 			for _, p := range partitions { | ||||
| 				if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs { | ||||
| 					addFsStat(p.Device, p.Mountpoint, false) | ||||
| 					found = true | ||||
| 					break | ||||
| 				} | ||||
| 			} | ||||
| 			// if not in partitions, test if we can get disk usage | ||||
| 			if !found { | ||||
| 				if _, err := disk.Usage(fs); err == nil { | ||||
| 					addFsStat(filepath.Base(fs), fs, false) | ||||
| 				} else { | ||||
| 					slog.Error("Invalid filesystem", "name", fs, "err", err) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Process partitions for various mount points | ||||
| 	for _, p := range partitions { | ||||
| 		// fmt.Println(p.Device, p.Mountpoint) | ||||
| 		// Binary root fallback or docker root fallback | ||||
| 		if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) { | ||||
| 			fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats) | ||||
| 			if match { | ||||
| 				addFsStat(fs, p.Mountpoint, true) | ||||
| 				hasRoot = true | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Check if device is in /extra-filesystems | ||||
| 		if strings.HasPrefix(p.Mountpoint, efPath) { | ||||
| 			addFsStat(p.Device, p.Mountpoint, false) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Check all folders in /extra-filesystems and add them if not already present | ||||
| 	if folders, err := os.ReadDir(efPath); err == nil { | ||||
| 		existingMountpoints := make(map[string]bool) | ||||
| 		for _, stats := range a.fsStats { | ||||
| 			existingMountpoints[stats.Mountpoint] = true | ||||
| 		} | ||||
| 		for _, folder := range folders { | ||||
| 			if folder.IsDir() { | ||||
| 				mountpoint := filepath.Join(efPath, folder.Name()) | ||||
| 				slog.Debug("/extra-filesystems", "mountpoint", mountpoint) | ||||
| 				if !existingMountpoints[mountpoint] { | ||||
| 					addFsStat(folder.Name(), mountpoint, false) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// If no root filesystem set, use fallback | ||||
| 	if !hasRoot { | ||||
| 		rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats) | ||||
| 		slog.Info("Root disk", "mountpoint", "/", "io", rootDevice) | ||||
| 		a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"} | ||||
| 	} | ||||
|  | ||||
| 	a.initializeDiskIoStats(diskIoCounters) | ||||
| } | ||||
|  | ||||
| // Returns matching device from /proc/diskstats, | ||||
| // or the device with the most reads if no match is found. | ||||
| // 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 | ||||
| 	maxReadDevice := "/" | ||||
| 	for _, d := range diskIoCounters { | ||||
| 		if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) { | ||||
| 			return d.Name, true | ||||
| 		} | ||||
| 		if d.ReadBytes > maxReadBytes { | ||||
| 			// don't use if device already exists in fsStats | ||||
| 			if _, exists := fsStats[d.Name]; !exists { | ||||
| 				maxReadBytes = d.ReadBytes | ||||
| 				maxReadDevice = d.Name | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return maxReadDevice, false | ||||
| } | ||||
|  | ||||
| // Sets start values for disk I/O stats. | ||||
| func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) { | ||||
| 	for device, stats := range a.fsStats { | ||||
| 		// skip if not in diskIoCounters | ||||
| 		d, exists := diskIoCounters[device] | ||||
| 		if !exists { | ||||
| 			slog.Warn("Device not found in diskstats", "name", device) | ||||
| 			continue | ||||
| 		} | ||||
| 		// populate initial values | ||||
| 		stats.Time = time.Now() | ||||
| 		stats.TotalRead = d.ReadBytes | ||||
| 		stats.TotalWrite = d.WriteBytes | ||||
| 		// add to list of valid io device names | ||||
| 		a.fsNames = append(a.fsNames, device) | ||||
| 	} | ||||
| } | ||||
| @@ -1,12 +0,0 @@ | ||||
| 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 | ||||
|     environment: | ||||
|       PORT: 45876 | ||||
|       KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY' | ||||
|       # FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats | ||||
							
								
								
									
										370
									
								
								agent/docker.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,370 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/container" | ||||
|  | ||||
| 	"github.com/blang/semver" | ||||
| ) | ||||
|  | ||||
| type dockerManager struct { | ||||
| 	client              *http.Client                // Client to query Docker API | ||||
| 	wg                  sync.WaitGroup              // WaitGroup to wait for all goroutines to finish | ||||
| 	sem                 chan struct{}               // Semaphore to limit concurrent container requests | ||||
| 	containerStatsMutex sync.RWMutex                // Mutex to prevent concurrent access to containerStatsMap | ||||
| 	apiContainerList    []*container.ApiInfo        // List of containers from Docker API (no pointer) | ||||
| 	containerStatsMap   map[string]*container.Stats // Keeps track of container stats | ||||
| 	validIds            map[string]struct{}         // Map of valid container ids, used to prune invalid containers from containerStatsMap | ||||
| 	goodDockerVersion   bool                        // Whether docker version is at least 25.0.0 (one-shot works correctly) | ||||
| 	isWindows           bool                        // Whether the Docker Engine API is running on Windows | ||||
| 	buf                 *bytes.Buffer               // Buffer to store and read response bodies | ||||
| 	decoder             *json.Decoder               // Reusable JSON decoder that reads from buf | ||||
| 	apiStats            *container.ApiStats         // Reusable API stats object | ||||
| } | ||||
|  | ||||
| // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests | ||||
| type userAgentRoundTripper struct { | ||||
| 	rt        http.RoundTripper | ||||
| 	userAgent string | ||||
| } | ||||
|  | ||||
| // RoundTrip implements the http.RoundTripper interface | ||||
| func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | ||||
| 	req.Header.Set("User-Agent", u.userAgent) | ||||
| 	return u.rt.RoundTrip(req) | ||||
| } | ||||
|  | ||||
| // Add goroutine to the queue | ||||
| func (d *dockerManager) queue() { | ||||
| 	d.wg.Add(1) | ||||
| 	if d.goodDockerVersion { | ||||
| 		d.sem <- struct{}{} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Remove goroutine from the queue | ||||
| func (d *dockerManager) dequeue() { | ||||
| 	d.wg.Done() | ||||
| 	if d.goodDockerVersion { | ||||
| 		<-d.sem | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Returns stats for all running containers | ||||
| func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) { | ||||
| 	resp, err := dm.client.Get("http://localhost/containers/json") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	dm.apiContainerList = dm.apiContainerList[:0] | ||||
| 	if err := dm.decode(resp, &dm.apiContainerList); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows") | ||||
|  | ||||
| 	containersLength := len(dm.apiContainerList) | ||||
|  | ||||
| 	// store valid ids to clean up old container ids from map | ||||
| 	if dm.validIds == nil { | ||||
| 		dm.validIds = make(map[string]struct{}, containersLength) | ||||
| 	} else { | ||||
| 		clear(dm.validIds) | ||||
| 	} | ||||
|  | ||||
| 	var failedContainers []*container.ApiInfo | ||||
|  | ||||
| 	for i := range dm.apiContainerList { | ||||
| 		ctr := dm.apiContainerList[i] | ||||
| 		ctr.IdShort = ctr.Id[:12] | ||||
| 		dm.validIds[ctr.IdShort] = struct{}{} | ||||
| 		// check if container is less than 1 minute old (possible restart) | ||||
| 		// note: can't use Created field because it's not updated on restart | ||||
| 		if strings.Contains(ctr.Status, "second") { | ||||
| 			// if so, remove old container data | ||||
| 			dm.deleteContainerStatsSync(ctr.IdShort) | ||||
| 		} | ||||
| 		dm.queue() | ||||
| 		go func() { | ||||
| 			defer dm.dequeue() | ||||
| 			err := dm.updateContainerStats(ctr) | ||||
| 			// if error, delete from map and add to failed list to retry | ||||
| 			if err != nil { | ||||
| 				dm.containerStatsMutex.Lock() | ||||
| 				delete(dm.containerStatsMap, ctr.IdShort) | ||||
| 				failedContainers = append(failedContainers, ctr) | ||||
| 				dm.containerStatsMutex.Unlock() | ||||
| 			} | ||||
| 		}() | ||||
| 	} | ||||
|  | ||||
| 	dm.wg.Wait() | ||||
|  | ||||
| 	// retry failed containers separately so we can run them in parallel (docker 24 bug) | ||||
| 	if len(failedContainers) > 0 { | ||||
| 		slog.Debug("Retrying failed containers", "count", len(failedContainers)) | ||||
| 		for i := range failedContainers { | ||||
| 			ctr := failedContainers[i] | ||||
| 			dm.queue() | ||||
| 			go func() { | ||||
| 				defer dm.dequeue() | ||||
| 				err = dm.updateContainerStats(ctr) | ||||
| 				if err != nil { | ||||
| 					slog.Error("Error getting container stats", "err", err) | ||||
| 				} | ||||
| 			}() | ||||
| 		} | ||||
| 		dm.wg.Wait() | ||||
| 	} | ||||
|  | ||||
| 	// populate final stats and remove old / invalid container stats | ||||
| 	stats := make([]*container.Stats, 0, containersLength) | ||||
| 	for id, v := range dm.containerStatsMap { | ||||
| 		if _, exists := dm.validIds[id]; !exists { | ||||
| 			delete(dm.containerStatsMap, id) | ||||
| 		} else { | ||||
| 			stats = append(stats, v) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return stats, nil | ||||
| } | ||||
|  | ||||
| // Updates stats for individual container | ||||
| func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error { | ||||
| 	name := ctr.Names[0][1:] | ||||
|  | ||||
| 	resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	dm.containerStatsMutex.Lock() | ||||
| 	defer dm.containerStatsMutex.Unlock() | ||||
|  | ||||
| 	// add empty values if they doesn't exist in map | ||||
| 	stats, initialized := dm.containerStatsMap[ctr.IdShort] | ||||
| 	if !initialized { | ||||
| 		stats = &container.Stats{Name: name} | ||||
| 		dm.containerStatsMap[ctr.IdShort] = stats | ||||
| 	} | ||||
|  | ||||
| 	// reset current stats | ||||
| 	stats.Cpu = 0 | ||||
| 	stats.Mem = 0 | ||||
| 	stats.NetworkSent = 0 | ||||
| 	stats.NetworkRecv = 0 | ||||
|  | ||||
| 	// docker host container stats response | ||||
| 	// res := dm.getApiStats() | ||||
| 	// defer dm.putApiStats(res) | ||||
| 	// | ||||
|  | ||||
| 	res := dm.apiStats | ||||
| 	res.Networks = nil | ||||
| 	if err := dm.decode(resp, res); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// calculate cpu and memory stats | ||||
| 	var usedMemory uint64 | ||||
| 	var cpuPct float64 | ||||
|  | ||||
| 	// store current cpu stats | ||||
| 	prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem | ||||
| 	stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage | ||||
| 	stats.CpuSystem = res.CPUStats.SystemUsage | ||||
|  | ||||
| 	if dm.isWindows { | ||||
| 		usedMemory = res.MemoryStats.PrivateWorkingSet | ||||
| 		cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime) | ||||
| 	} else { | ||||
| 		// check if container has valid data, otherwise may be in restart loop (#103) | ||||
| 		if res.MemoryStats.Usage == 0 { | ||||
| 			return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name) | ||||
| 		} | ||||
| 		memCache := res.MemoryStats.Stats.InactiveFile | ||||
| 		if memCache == 0 { | ||||
| 			memCache = res.MemoryStats.Stats.Cache | ||||
| 		} | ||||
| 		usedMemory = res.MemoryStats.Usage - memCache | ||||
|  | ||||
| 		cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem) | ||||
| 	} | ||||
|  | ||||
| 	if cpuPct > 100 { | ||||
| 		return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) | ||||
| 	} | ||||
|  | ||||
| 	// network | ||||
| 	var total_sent, total_recv uint64 | ||||
| 	for _, v := range res.Networks { | ||||
| 		total_sent += v.TxBytes | ||||
| 		total_recv += v.RxBytes | ||||
| 	} | ||||
| 	var sent_delta, recv_delta uint64 | ||||
| 	millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds()) | ||||
| 	if initialized && millisecondsElapsed > 0 { | ||||
| 		// get bytes per second | ||||
| 		sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed | ||||
| 		recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed | ||||
| 		// check for unrealistic network values (> 5GB/s) | ||||
| 		if sent_delta > 5e9 || recv_delta > 5e9 { | ||||
| 			slog.Warn("Bad network delta", "container", name) | ||||
| 			sent_delta, recv_delta = 0, 0 | ||||
| 		} | ||||
| 	} | ||||
| 	stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv | ||||
|  | ||||
| 	stats.Cpu = twoDecimals(cpuPct) | ||||
| 	stats.Mem = bytesToMegabytes(float64(usedMemory)) | ||||
| 	stats.NetworkSent = bytesToMegabytes(float64(sent_delta)) | ||||
| 	stats.NetworkRecv = bytesToMegabytes(float64(recv_delta)) | ||||
| 	stats.PrevReadTime = res.Read | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Delete container stats from map using mutex | ||||
| func (dm *dockerManager) deleteContainerStatsSync(id string) { | ||||
| 	dm.containerStatsMutex.Lock() | ||||
| 	defer dm.containerStatsMutex.Unlock() | ||||
| 	delete(dm.containerStatsMap, id) | ||||
| } | ||||
|  | ||||
| // Creates a new http client for Docker or Podman API | ||||
| func newDockerManager(a *Agent) *dockerManager { | ||||
| 	dockerHost, exists := GetEnv("DOCKER_HOST") | ||||
| 	if exists { | ||||
| 		// return nil if set to empty string | ||||
| 		if dockerHost == "" { | ||||
| 			return nil | ||||
| 		} | ||||
| 	} else { | ||||
| 		dockerHost = getDockerHost() | ||||
| 	} | ||||
|  | ||||
| 	parsedURL, err := url.Parse(dockerHost) | ||||
| 	if err != nil { | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	transport := &http.Transport{ | ||||
| 		DisableCompression: true, | ||||
| 		MaxConnsPerHost:    0, | ||||
| 	} | ||||
|  | ||||
| 	switch parsedURL.Scheme { | ||||
| 	case "unix": | ||||
| 		transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) { | ||||
| 			return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path) | ||||
| 		} | ||||
| 	case "tcp", "http", "https": | ||||
| 		transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) { | ||||
| 			return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host) | ||||
| 		} | ||||
| 	default: | ||||
| 		slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	// configurable timeout | ||||
| 	timeout := time.Millisecond * 2100 | ||||
| 	if t, set := GetEnv("DOCKER_TIMEOUT"); set { | ||||
| 		timeout, err = time.ParseDuration(t) | ||||
| 		if err != nil { | ||||
| 			slog.Error(err.Error()) | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 		slog.Info("DOCKER_TIMEOUT", "timeout", timeout) | ||||
| 	} | ||||
|  | ||||
| 	// Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575 | ||||
| 	userAgentTransport := &userAgentRoundTripper{ | ||||
| 		rt:        transport, | ||||
| 		userAgent: "Docker-Client/", | ||||
| 	} | ||||
|  | ||||
| 	manager := &dockerManager{ | ||||
| 		client: &http.Client{ | ||||
| 			Timeout:   timeout, | ||||
| 			Transport: userAgentTransport, | ||||
| 		}, | ||||
| 		containerStatsMap: make(map[string]*container.Stats), | ||||
| 		sem:               make(chan struct{}, 5), | ||||
| 		apiContainerList:  []*container.ApiInfo{}, | ||||
| 		apiStats:          &container.ApiStats{}, | ||||
| 	} | ||||
|  | ||||
| 	// If using podman, return client | ||||
| 	if strings.Contains(dockerHost, "podman") { | ||||
| 		a.systemInfo.Podman = true | ||||
| 		manager.goodDockerVersion = true | ||||
| 		return manager | ||||
| 	} | ||||
|  | ||||
| 	// Check docker version | ||||
| 	// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch) | ||||
| 	var versionInfo struct { | ||||
| 		Version string `json:"Version"` | ||||
| 	} | ||||
| 	resp, err := manager.client.Get("http://localhost/version") | ||||
| 	if err != nil { | ||||
| 		return manager | ||||
| 	} | ||||
|  | ||||
| 	if err := manager.decode(resp, &versionInfo); err != nil { | ||||
| 		return manager | ||||
| 	} | ||||
|  | ||||
| 	// if version > 24, one-shot works correctly and we can limit concurrent operations | ||||
| 	if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { | ||||
| 		manager.goodDockerVersion = true | ||||
| 	} else { | ||||
| 		slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version)) | ||||
| 	} | ||||
|  | ||||
| 	return manager | ||||
| } | ||||
|  | ||||
| // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. | ||||
| func (dm *dockerManager) decode(resp *http.Response, d any) error { | ||||
| 	if dm.buf == nil { | ||||
| 		// initialize buffer with 256kb starting size | ||||
| 		dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) | ||||
| 		dm.decoder = json.NewDecoder(dm.buf) | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 	defer dm.buf.Reset() | ||||
| 	_, err := dm.buf.ReadFrom(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return dm.decoder.Decode(d) | ||||
| } | ||||
|  | ||||
| // 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] | ||||
| } | ||||
| @@ -1,20 +0,0 @@ | ||||
| FROM --platform=$BUILDPLATFORM golang:alpine as builder | ||||
|  | ||||
| WORKDIR /app | ||||
|  | ||||
| # Download Go modules | ||||
| COPY go.mod go.sum ./ | ||||
| RUN go mod download | ||||
|  | ||||
| COPY *.go ./ | ||||
|  | ||||
| # Build | ||||
| ARG TARGETOS TARGETARCH | ||||
| RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent . | ||||
|  | ||||
| # ? ------------------------- | ||||
| FROM scratch | ||||
|  | ||||
| COPY --from=builder /agent /agent | ||||
|  | ||||
| ENTRYPOINT ["/agent"] | ||||
							
								
								
									
										32
									
								
								agent/go.mod
									
									
									
									
									
								
							
							
						
						| @@ -1,32 +0,0 @@ | ||||
| module beszel-agent | ||||
|  | ||||
| go 1.22.4 | ||||
|  | ||||
| require ( | ||||
| 	github.com/blang/semver v3.5.1+incompatible | ||||
| 	github.com/gliderlabs/ssh v0.3.7 | ||||
| 	github.com/rhysd/go-github-selfupdate v1.2.3 | ||||
| 	github.com/shirou/gopsutil/v4 v4.24.6 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect | ||||
| 	github.com/go-ole/go-ole v1.3.0 // indirect | ||||
| 	github.com/golang/protobuf v1.3.2 // indirect | ||||
| 	github.com/google/go-github/v30 v30.1.0 // indirect | ||||
| 	github.com/google/go-querystring v1.0.0 // indirect | ||||
| 	github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect | ||||
| 	github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect | ||||
| 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect | ||||
| 	github.com/shoenig/go-m1cpu v0.1.6 // indirect | ||||
| 	github.com/tcnksm/go-gitconfig v0.1.2 // indirect | ||||
| 	github.com/tklauser/go-sysconf v0.3.14 // indirect | ||||
| 	github.com/tklauser/numcpus v0.8.0 // indirect | ||||
| 	github.com/ulikunitz/xz v0.5.9 // indirect | ||||
| 	github.com/yusufpapurcu/wmi v1.2.4 // indirect | ||||
| 	golang.org/x/crypto v0.25.0 // indirect | ||||
| 	golang.org/x/net v0.21.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 // indirect | ||||
| 	golang.org/x/sys v0.22.0 // indirect | ||||
| 	google.golang.org/appengine v1.3.0 // indirect | ||||
| ) | ||||
							
								
								
									
										97
									
								
								agent/go.sum
									
									
									
									
									
								
							
							
						
						| @@ -1,97 +0,0 @@ | ||||
| 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/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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= | ||||
| github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= | ||||
| github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= | ||||
| github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= | ||||
| github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | ||||
| github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | ||||
| github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||
| github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= | ||||
| github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||
| github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= | ||||
| github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= | ||||
| github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= | ||||
| github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= | ||||
| 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/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= | ||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||||
| 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/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= | ||||
| github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= | ||||
| github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= | ||||
| github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= | ||||
| github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| 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/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= | ||||
| github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= | ||||
| github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64= | ||||
| github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA= | ||||
| github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= | ||||
| github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= | ||||
| github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= | ||||
| github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= | ||||
| github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= | ||||
| github.com/stretchr/testify v1.9.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/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/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= | ||||
| github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= | ||||
| github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= | ||||
| github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= | ||||
| github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= | ||||
| github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= | ||||
| github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= | ||||
| golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= | ||||
| golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= | ||||
| golang.org/x/net v0.0.0-20180724234803-3673e40ba225/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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||
| golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||||
| golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= | ||||
| golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= | ||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= | ||||
| golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| 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-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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= | ||||
| golang.org/x/sys v0.22.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.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= | ||||
| golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= | ||||
| golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||
| google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= | ||||
| google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= | ||||
| gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= | ||||
| gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= | ||||
| gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
							
								
								
									
										349
									
								
								agent/gpu.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,349 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"os/exec" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"golang.org/x/exp/slog" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	// Commands | ||||
| 	nvidiaSmiCmd  string = "nvidia-smi" | ||||
| 	rocmSmiCmd    string = "rocm-smi" | ||||
| 	tegraStatsCmd string = "tegrastats" | ||||
|  | ||||
| 	// Polling intervals | ||||
| 	nvidiaSmiInterval  string        = "4"    // in seconds | ||||
| 	tegraStatsInterval string        = "3700" // in milliseconds | ||||
| 	rocmSmiInterval    time.Duration = 4300 * time.Millisecond | ||||
|  | ||||
| 	// Command retry and timeout constants | ||||
| 	retryWaitTime     time.Duration = 5 * time.Second | ||||
| 	maxFailureRetries int           = 5 | ||||
|  | ||||
| 	cmdBufferSize uint16 = 10 * 1024 | ||||
|  | ||||
| 	// Unit Conversions | ||||
| 	mebibytesInAMegabyte float64 = 1.024  // nvidia-smi reports memory in MiB | ||||
| 	milliwattsInAWatt    float64 = 1000.0 // tegrastats reports power in mW | ||||
| ) | ||||
|  | ||||
| // 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(retryWaitTime) | ||||
| 			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, cmdBufferSize) | ||||
| 	} | ||||
| 	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`) | ||||
|  | ||||
| 	// jetson devices have only one gpu so we'll just initialize here | ||||
| 	gpuData := &system.GPUData{Name: "GPU"} | ||||
| 	gm.GpuDataMap["0"] = gpuData | ||||
|  | ||||
| 	return func(output []byte) bool { | ||||
| 		gm.Lock() | ||||
| 		defer gm.Unlock() | ||||
| 		// Parse RAM usage | ||||
| 		ramMatches := ramPattern.FindSubmatch(output) | ||||
| 		if ramMatches != nil { | ||||
| 			gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64) | ||||
| 			gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64) | ||||
| 		} | ||||
| 		// Parse GR3D (GPU) usage | ||||
| 		gr3dMatches := gr3dPattern.FindSubmatch(output) | ||||
| 		if gr3dMatches != nil { | ||||
| 			gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64) | ||||
| 			gpuData.Usage += gr3dUsage | ||||
| 		} | ||||
| 		// Parse temperature | ||||
| 		tempMatches := tempPattern.FindSubmatch(output) | ||||
| 		if tempMatches != nil { | ||||
| 			gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64) | ||||
| 		} | ||||
| 		// Parse power usage | ||||
| 		powerMatches := powerPattern.FindSubmatch(output) | ||||
| 		if powerMatches != nil { | ||||
| 			power, _ := strconv.ParseFloat(string(powerMatches[2]), 64) | ||||
| 			gpuData.Power += power / milliwattsInAWatt | ||||
| 		} | ||||
| 		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() | ||||
| 	scanner := bufio.NewScanner(bytes.NewReader(output)) | ||||
| 	var valid bool | ||||
| 	for scanner.Scan() { | ||||
| 		line := scanner.Text() // Or use scanner.Bytes() for []byte | ||||
| 		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")} | ||||
| 		} | ||||
| 		// update gpu data | ||||
| 		gpu := gm.GpuDataMap[id] | ||||
| 		gpu.Temperature = temp | ||||
| 		gpu.MemoryUsed = memoryUsage / mebibytesInAMegabyte | ||||
| 		gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte | ||||
| 		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 { | ||||
| 		gpuAvg := *gpu | ||||
|  | ||||
| 		gpuAvg.Temperature = twoDecimals(gpu.Temperature) | ||||
| 		gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed) | ||||
| 		gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal) | ||||
|  | ||||
| 		// avoid division by zero | ||||
| 		if gpu.Count > 0 { | ||||
| 			gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count) | ||||
| 			gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count) | ||||
| 		} | ||||
|  | ||||
| 		// reset accumulators in the original | ||||
| 		gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0 | ||||
|  | ||||
| 		// append id to the name if there are multiple GPUs with the same name | ||||
| 		if nameCounts[gpu.Name] > 1 { | ||||
| 			gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id) | ||||
| 		} | ||||
| 		gpuData[id] = gpuAvg | ||||
| 	} | ||||
| 	slog.Debug("GPU", "data", gpuData) | ||||
| 	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(nvidiaSmiCmd); err == nil { | ||||
| 		gm.nvidiaSmi = true | ||||
| 	} | ||||
| 	if _, err := exec.LookPath(rocmSmiCmd); err == nil { | ||||
| 		gm.rocmSmi = true | ||||
| 	} | ||||
| 	if _, err := exec.LookPath(tegraStatsCmd); err == nil { | ||||
| 		gm.tegrastats = true | ||||
| 		gm.nvidiaSmi = false | ||||
| 	} | ||||
| 	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 nvidiaSmiCmd: | ||||
| 		collector.cmdArgs = []string{ | ||||
| 			"-l", nvidiaSmiInterval, | ||||
| 			"--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 tegraStatsCmd: | ||||
| 		collector.cmdArgs = []string{"--interval", tegraStatsInterval} | ||||
| 		collector.parse = gm.getJetsonParser() | ||||
| 		go collector.start() | ||||
| 	case rocmSmiCmd: | ||||
| 		collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"} | ||||
| 		collector.parse = gm.parseAmdData | ||||
| 		go func() { | ||||
| 			failures := 0 | ||||
| 			for { | ||||
| 				if err := collector.collect(); err != nil { | ||||
| 					failures++ | ||||
| 					if failures > maxFailureRetries { | ||||
| 						break | ||||
| 					} | ||||
| 					slog.Warn("Error collecting AMD GPU data", "err", err) | ||||
| 				} | ||||
| 				time.Sleep(rocmSmiInterval) | ||||
| 			} | ||||
| 		}() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // 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(nvidiaSmiCmd) | ||||
| 	} | ||||
| 	if gm.rocmSmi { | ||||
| 		gm.startCollector(rocmSmiCmd) | ||||
| 	} | ||||
| 	if gm.tegrastats { | ||||
| 		gm.startCollector(tegraStatsCmd) | ||||
| 	} | ||||
|  | ||||
| 	return &gm, nil | ||||
| } | ||||
							
								
								
									
										794
									
								
								agent/gpu_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,794 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"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: "more valid multi-gpu data", | ||||
| 			input: `0, NVIDIA A10, 45, 19676, 23028, 0, 58.98 | ||||
| 1, NVIDIA A10, 45, 19638, 23028, 0, 62.35 | ||||
| 2, NVIDIA A10, 44, 21700, 23028, 0, 59.57 | ||||
| 3, NVIDIA A10, 45, 18222, 23028, 0, 61.76`, | ||||
| 			wantData: map[string]system.GPUData{ | ||||
| 				"0": { | ||||
| 					Name:        "A10", | ||||
| 					Temperature: 45.0, | ||||
| 					MemoryUsed:  19676.0 / 1.024, | ||||
| 					MemoryTotal: 23028.0 / 1.024, | ||||
| 					Usage:       0.0, | ||||
| 					Power:       58.98, | ||||
| 					Count:       1, | ||||
| 				}, | ||||
| 				"1": { | ||||
| 					Name:        "A10", | ||||
| 					Temperature: 45.0, | ||||
| 					MemoryUsed:  19638.0 / 1.024, | ||||
| 					MemoryTotal: 23028.0 / 1.024, | ||||
| 					Usage:       0.0, | ||||
| 					Power:       62.35, | ||||
| 					Count:       1, | ||||
| 				}, | ||||
| 				"2": { | ||||
| 					Name:        "A10", | ||||
| 					Temperature: 44.0, | ||||
| 					MemoryUsed:  21700.0 / 1.024, | ||||
| 					MemoryTotal: 23028.0 / 1.024, | ||||
| 					Usage:       0.0, | ||||
| 					Power:       59.57, | ||||
| 					Count:       1, | ||||
| 				}, | ||||
| 				"3": { | ||||
| 					Name:        "A10", | ||||
| 					Temperature: 45.0, | ||||
| 					MemoryUsed:  18222.0 / 1.024, | ||||
| 					MemoryTotal: 23028.0 / 1.024, | ||||
| 					Usage:       0.0, | ||||
| 					Power:       61.76, | ||||
| 					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 | ||||
| 		wantMetrics *system.GPUData | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:  "valid data", | ||||
| 			input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW", | ||||
| 			wantMetrics: &system.GPUData{ | ||||
| 				Name:        "GPU", | ||||
| 				MemoryUsed:  4300.0, | ||||
| 				MemoryTotal: 30698.0, | ||||
| 				Usage:       45.0, | ||||
| 				Temperature: 52.468, | ||||
| 				Power:       2.171, | ||||
| 				Count:       1, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:  "more valid data", | ||||
| 			input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW", | ||||
| 			wantMetrics: &system.GPUData{ | ||||
| 				Name:        "GPU", | ||||
| 				MemoryUsed:  6185.0, | ||||
| 				MemoryTotal: 7620.0, | ||||
| 				Usage:       63.0, | ||||
| 				Temperature: 53.968, | ||||
| 				Power:       4.667, | ||||
| 				Count:       1, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:  "orin nano", | ||||
| 			input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW", | ||||
| 			wantMetrics: &system.GPUData{ | ||||
| 				Name:        "GPU", | ||||
| 				MemoryUsed:  3452.0, | ||||
| 				MemoryTotal: 7620.0, | ||||
| 				Usage:       0.0, | ||||
| 				Temperature: 50.25, | ||||
| 				Power:       0.518, | ||||
| 				Count:       1, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:  "missing temperature", | ||||
| 			input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW", | ||||
| 			wantMetrics: &system.GPUData{ | ||||
| 				Name:        "GPU", | ||||
| 				MemoryUsed:  4300.0, | ||||
| 				MemoryTotal: 30698.0, | ||||
| 				Usage:       45.0, | ||||
| 				Power:       2.171, | ||||
| 				Count:       1, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			gm := &GPUManager{ | ||||
| 				GpuDataMap: make(map[string]*system.GPUData), | ||||
| 			} | ||||
| 			parser := gm.getJetsonParser() | ||||
| 			valid := parser([]byte(tt.input)) | ||||
| 			assert.Equal(t, true, valid) | ||||
|  | ||||
| 			got := 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) { | ||||
| 	t.Run("calculates averages and resets accumulators", func(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, | ||||
| 				}, | ||||
| 				"2": { | ||||
| 					Name:        "GPU 2", | ||||
| 					Temperature: 70, | ||||
| 					MemoryUsed:  4096, | ||||
| 					MemoryTotal: 8192, | ||||
| 					Usage:       200, | ||||
| 					Power:       400, | ||||
| 					Count:       1, | ||||
| 				}, | ||||
| 			}, | ||||
| 		} | ||||
|  | ||||
| 		result := gm.GetCurrentData() | ||||
|  | ||||
| 		// Verify name disambiguation | ||||
| 		assert.Equal(t, "GPU1 0", result["0"].Name) | ||||
| 		assert.Equal(t, "GPU1 1", result["1"].Name) | ||||
| 		assert.Equal(t, "GPU 2", result["2"].Name) | ||||
|  | ||||
| 		// Check averaged values in the result | ||||
| 		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 that accumulators in the original map are reset | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset") | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset") | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset") | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset") | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset") | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("handles zero count without panicking", func(t *testing.T) { | ||||
| 		gm := &GPUManager{ | ||||
| 			GpuDataMap: map[string]*system.GPUData{ | ||||
| 				"0": { | ||||
| 					Name:  "TestGPU", | ||||
| 					Count: 0, | ||||
| 					Usage: 0, | ||||
| 					Power: 0, | ||||
| 				}, | ||||
| 			}, | ||||
| 		} | ||||
|  | ||||
| 		var result map[string]system.GPUData | ||||
| 		assert.NotPanics(t, func() { | ||||
| 			result = gm.GetCurrentData() | ||||
| 		}) | ||||
|  | ||||
| 		// Check that usage and power are 0 | ||||
| 		assert.Equal(t, 0.0, result["0"].Usage) | ||||
| 		assert.Equal(t, 0.0, result["0"].Power) | ||||
|  | ||||
| 		// Verify reset count | ||||
| 		assert.Equal(t, float64(0), gm.GpuDataMap["0"].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:  false, | ||||
| 			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 "11-14-2024 22:54:33 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) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestAccumulationTableDriven tests the accumulation behavior for all three GPU types | ||||
| func TestAccumulation(t *testing.T) { | ||||
| 	type expectedGPUValues struct { | ||||
| 		temperature float64 | ||||
| 		memoryUsed  float64 | ||||
| 		memoryTotal float64 | ||||
| 		usage       float64 | ||||
| 		power       float64 | ||||
| 		count       float64 | ||||
| 		avgUsage    float64 | ||||
| 		avgPower    float64 | ||||
| 	} | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name           string | ||||
| 		initialGPUData map[string]*system.GPUData | ||||
| 		dataSamples    [][]byte | ||||
| 		parser         func(*GPUManager) func([]byte) bool | ||||
| 		expectedValues map[string]expectedGPUValues | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "Jetson GPU accumulation", | ||||
| 			initialGPUData: map[string]*system.GPUData{ | ||||
| 				"0": { | ||||
| 					Name:        "Jetson", | ||||
| 					Temperature: 0, | ||||
| 					Usage:       0, | ||||
| 					Power:       0, | ||||
| 					Count:       0, | ||||
| 				}, | ||||
| 			}, | ||||
| 			dataSamples: [][]byte{ | ||||
| 				[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 30% tj@50.5C VDD_GPU_SOC 1000mW"), | ||||
| 				[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 40% tj@60.5C VDD_GPU_SOC 1200mW"), | ||||
| 				[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 50% tj@70.5C VDD_GPU_SOC 1400mW"), | ||||
| 			}, | ||||
| 			parser: func(gm *GPUManager) func([]byte) bool { | ||||
| 				return gm.getJetsonParser() | ||||
| 			}, | ||||
| 			expectedValues: map[string]expectedGPUValues{ | ||||
| 				"0": { | ||||
| 					temperature: 70.5,  // Last value | ||||
| 					memoryUsed:  1024,  // Last value | ||||
| 					memoryTotal: 4096,  // Last value | ||||
| 					usage:       120.0, // Accumulated: 30 + 40 + 50 | ||||
| 					power:       3.6,   // Accumulated: 1.0 + 1.2 + 1.4 | ||||
| 					count:       3, | ||||
| 					avgUsage:    40.0, // 120 / 3 | ||||
| 					avgPower:    1.2,  // 3.6 / 3 | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "NVIDIA GPU accumulation", | ||||
| 			initialGPUData: map[string]*system.GPUData{ | ||||
| 				// NVIDIA parser will create the GPU data entries | ||||
| 			}, | ||||
| 			dataSamples: [][]byte{ | ||||
| 				[]byte("0, NVIDIA GeForce RTX 3080, 50, 5000, 10000, 30, 200"), | ||||
| 				[]byte("0, NVIDIA GeForce RTX 3080, 60, 6000, 10000, 40, 250"), | ||||
| 				[]byte("0, NVIDIA GeForce RTX 3080, 70, 7000, 10000, 50, 300"), | ||||
| 			}, | ||||
| 			parser: func(gm *GPUManager) func([]byte) bool { | ||||
| 				return gm.parseNvidiaData | ||||
| 			}, | ||||
| 			expectedValues: map[string]expectedGPUValues{ | ||||
| 				"0": { | ||||
| 					temperature: 70.0,            // Last value | ||||
| 					memoryUsed:  7000.0 / 1.024,  // Last value | ||||
| 					memoryTotal: 10000.0 / 1.024, // Last value | ||||
| 					usage:       120.0,           // Accumulated: 30 + 40 + 50 | ||||
| 					power:       750.0,           // Accumulated: 200 + 250 + 300 | ||||
| 					count:       3, | ||||
| 					avgUsage:    40.0,  // 120 / 3 | ||||
| 					avgPower:    250.0, // 750 / 3 | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "AMD GPU accumulation", | ||||
| 			initialGPUData: map[string]*system.GPUData{ | ||||
| 				// AMD parser will create the GPU data entries | ||||
| 			}, | ||||
| 			dataSamples: [][]byte{ | ||||
| 				[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "50.0", "Current Socket Graphics Package Power (W)": "100.0", "GPU use (%)": "30", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "1073741824", "Card Series": "Radeon RX 6800"}}`), | ||||
| 				[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "60.0", "Current Socket Graphics Package Power (W)": "150.0", "GPU use (%)": "40", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "2147483648", "Card Series": "Radeon RX 6800"}}`), | ||||
| 				[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "70.0", "Current Socket Graphics Package Power (W)": "200.0", "GPU use (%)": "50", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "3221225472", "Card Series": "Radeon RX 6800"}}`), | ||||
| 			}, | ||||
| 			parser: func(gm *GPUManager) func([]byte) bool { | ||||
| 				return gm.parseAmdData | ||||
| 			}, | ||||
| 			expectedValues: map[string]expectedGPUValues{ | ||||
| 				"34756": { | ||||
| 					temperature: 70.0,                          // Last value | ||||
| 					memoryUsed:  3221225472.0 / (1024 * 1024),  // Last value | ||||
| 					memoryTotal: 10737418240.0 / (1024 * 1024), // Last value | ||||
| 					usage:       120.0,                         // Accumulated: 30 + 40 + 50 | ||||
| 					power:       450.0,                         // Accumulated: 100 + 150 + 200 | ||||
| 					count:       3, | ||||
| 					avgUsage:    40.0,  // 120 / 3 | ||||
| 					avgPower:    150.0, // 450 / 3 | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// Create a new GPUManager for each test | ||||
| 			gm := &GPUManager{ | ||||
| 				GpuDataMap: tt.initialGPUData, | ||||
| 			} | ||||
|  | ||||
| 			// Get the parser function | ||||
| 			parser := tt.parser(gm) | ||||
|  | ||||
| 			// Process each data sample | ||||
| 			for i, sample := range tt.dataSamples { | ||||
| 				valid := parser(sample) | ||||
| 				assert.True(t, valid, "Sample %d should be valid", i) | ||||
| 			} | ||||
|  | ||||
| 			// Check accumulated values | ||||
| 			for id, expected := range tt.expectedValues { | ||||
| 				gpu, exists := gm.GpuDataMap[id] | ||||
| 				assert.True(t, exists, "GPU with ID %s should exist", id) | ||||
| 				if !exists { | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature should match") | ||||
| 				assert.InDelta(t, expected.memoryUsed, gpu.MemoryUsed, 0.01, "Memory used should match") | ||||
| 				assert.InDelta(t, expected.memoryTotal, gpu.MemoryTotal, 0.01, "Memory total should match") | ||||
| 				assert.InDelta(t, expected.usage, gpu.Usage, 0.01, "Usage should match") | ||||
| 				assert.InDelta(t, expected.power, gpu.Power, 0.01, "Power should match") | ||||
| 				assert.Equal(t, expected.count, gpu.Count, "Count should match") | ||||
| 			} | ||||
|  | ||||
| 			// Verify average calculation in GetCurrentData | ||||
| 			result := gm.GetCurrentData() | ||||
| 			for id, expected := range tt.expectedValues { | ||||
| 				gpu, exists := result[id] | ||||
| 				assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id) | ||||
| 				if !exists { | ||||
| 					continue | ||||
| 				} | ||||
|  | ||||
| 				assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature in GetCurrentData should match") | ||||
| 				assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match") | ||||
| 				assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match") | ||||
| 			} | ||||
|  | ||||
| 			// Verify that accumulators in the original map are reset | ||||
| 			for id := range tt.expectedValues { | ||||
| 				gpu, exists := gm.GpuDataMap[id] | ||||
| 				assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id) | ||||
| 				if !exists { | ||||
| 					continue | ||||
| 				} | ||||
| 				assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id) | ||||
| 				assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id) | ||||
| 				assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										43
									
								
								agent/health/health.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,43 @@ | ||||
| // Package health provides functions to check and update the health of the agent. | ||||
| // It uses a file in the temp directory to store the timestamp of the last connection attempt. | ||||
| // If the timestamp is older than 90 seconds, the agent is considered unhealthy. | ||||
| // NB: The agent must be started with the Start() method to be considered healthy. | ||||
| package health | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // healthFile is the path to the health file | ||||
| var healthFile = filepath.Join(os.TempDir(), "beszel_health") | ||||
|  | ||||
| // Check checks if the agent is connected by checking the modification time of the health file | ||||
| func Check() error { | ||||
| 	fileInfo, err := os.Stat(healthFile) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if time.Since(fileInfo.ModTime()) > 91*time.Second { | ||||
| 		log.Println("over 90 seconds since last connection") | ||||
| 		return errors.New("unhealthy") | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Update updates the modification time of the health file | ||||
| func Update() error { | ||||
| 	file, err := os.Create(healthFile) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return file.Close() | ||||
| } | ||||
|  | ||||
| // CleanUp removes the health file | ||||
| func CleanUp() error { | ||||
| 	return os.Remove(healthFile) | ||||
| } | ||||
							
								
								
									
										67
									
								
								agent/health/health_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,67 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package health | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"testing/synctest" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestHealth(t *testing.T) { | ||||
| 	// Override healthFile to use a temporary directory for this test. | ||||
| 	originalHealthFile := healthFile | ||||
| 	tmpDir := t.TempDir() | ||||
| 	healthFile = filepath.Join(tmpDir, "beszel_health_test") | ||||
| 	defer func() { healthFile = originalHealthFile }() | ||||
|  | ||||
| 	t.Run("check with no health file", func(t *testing.T) { | ||||
| 		err := Check() | ||||
| 		require.Error(t, err) | ||||
| 		assert.True(t, os.IsNotExist(err), "expected a file-not-exist error, but got: %v", err) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("update and check", func(t *testing.T) { | ||||
| 		err := Update() | ||||
| 		require.NoError(t, err, "Update() failed") | ||||
|  | ||||
| 		err = Check() | ||||
| 		assert.NoError(t, err, "Check() failed immediately after Update()") | ||||
| 	}) | ||||
|  | ||||
| 	// This test uses synctest to simulate time passing. | ||||
| 	// NOTE: This test requires GOEXPERIMENT=synctest to run. | ||||
| 	t.Run("check with simulated time", func(t *testing.T) { | ||||
| 		synctest.Test(t, func(t *testing.T) { | ||||
| 			// Update the file to set the initial timestamp. | ||||
| 			require.NoError(t, Update(), "Update() failed inside synctest") | ||||
|  | ||||
| 			// Set the mtime to the current fake time to align the file's timestamp with the simulated clock. | ||||
| 			now := time.Now() | ||||
| 			require.NoError(t, os.Chtimes(healthFile, now, now), "Chtimes failed") | ||||
|  | ||||
| 			// Wait a duration less than the threshold. | ||||
| 			time.Sleep(89 * time.Second) | ||||
| 			synctest.Wait() | ||||
|  | ||||
| 			// The check should still pass. | ||||
| 			assert.NoError(t, Check(), "Check() failed after 89s") | ||||
|  | ||||
| 			// Wait for the total duration to exceed the threshold. | ||||
| 			time.Sleep(5 * time.Second) | ||||
| 			synctest.Wait() | ||||
|  | ||||
| 			// The check should now fail as unhealthy. | ||||
| 			err := Check() | ||||
| 			require.Error(t, err, "Check() should have failed after 91s") | ||||
| 			assert.Equal(t, "unhealthy", err.Error(), "Check() returned wrong error") | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										80
									
								
								agent/lhm/beszel_lhm.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| using System; | ||||
| using System.Globalization; | ||||
| using LibreHardwareMonitor.Hardware; | ||||
|  | ||||
| class Program | ||||
| { | ||||
|   static void Main() | ||||
|   { | ||||
|     var computer = new Computer | ||||
|     { | ||||
|       IsCpuEnabled = true, | ||||
|       IsGpuEnabled = true, | ||||
|       IsMemoryEnabled = true, | ||||
|       IsMotherboardEnabled = true, | ||||
|       IsStorageEnabled = true, | ||||
|       // IsPsuEnabled = true, | ||||
|       // IsNetworkEnabled = true, | ||||
|     }; | ||||
|     computer.Open(); | ||||
|  | ||||
|     var reader = Console.In; | ||||
|     var writer = Console.Out; | ||||
|  | ||||
|     string line; | ||||
|     while ((line = reader.ReadLine()) != null) | ||||
|     { | ||||
|       if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase)) | ||||
|       { | ||||
|         foreach (var hw in computer.Hardware) | ||||
|         { | ||||
|           // process main hardware sensors | ||||
|           ProcessSensors(hw, writer); | ||||
|  | ||||
|           // process subhardware sensors | ||||
|           foreach (var subhardware in hw.SubHardware) | ||||
|           { | ||||
|             ProcessSensors(subhardware, writer); | ||||
|           } | ||||
|         } | ||||
|         // send empty line to signal end of sensor data | ||||
|         writer.WriteLine(); | ||||
|         writer.Flush(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     computer.Close(); | ||||
|   } | ||||
|  | ||||
|   static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer) | ||||
|   { | ||||
|     var updated = false; | ||||
|     foreach (var sensor in hardware.Sensors) | ||||
|     { | ||||
|       var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue; | ||||
|       if (!validTemp || sensor.Name.Contains("Distance")) | ||||
|       { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       if (!updated) | ||||
|       { | ||||
|         hardware.Update(); | ||||
|         updated = true; | ||||
|       } | ||||
|  | ||||
|       var name = sensor.Name; | ||||
|       // if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name. | ||||
|       // usually this is a number like Temperature 3 | ||||
|       if (sensor.Name.StartsWith("Temperature")) | ||||
|       { | ||||
|         name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11); | ||||
|       } | ||||
|  | ||||
|       // invariant culture assures the value is parsable as a float | ||||
|       var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture); | ||||
|       // write the name and value to the writer | ||||
|       writer.WriteLine($"{name}|{value}"); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								agent/lhm/beszel_lhm.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>net48</TargetFramework> | ||||
|     <Platforms>x64</Platforms> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
							
								
								
									
										379
									
								
								agent/main.go
									
									
									
									
									
								
							
							
						
						| @@ -1,379 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	sshServer "github.com/gliderlabs/ssh" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/cpu" | ||||
| 	"github.com/shirou/gopsutil/v4/disk" | ||||
| 	"github.com/shirou/gopsutil/v4/host" | ||||
| 	"github.com/shirou/gopsutil/v4/mem" | ||||
| 	psutilNet "github.com/shirou/gopsutil/v4/net" | ||||
| ) | ||||
|  | ||||
| var Version = "0.0.1" | ||||
|  | ||||
| var containerCpuMap = make(map[string][2]uint64) | ||||
| var containerCpuMutex = &sync.Mutex{} | ||||
|  | ||||
| var sem = make(chan struct{}, 15) | ||||
|  | ||||
| func acquireSemaphore() { | ||||
| 	sem <- struct{}{} | ||||
| } | ||||
|  | ||||
| func releaseSemaphore() { | ||||
| 	<-sem | ||||
| } | ||||
|  | ||||
| var diskIoStats = DiskIoStats{ | ||||
| 	Read:       0, | ||||
| 	Write:      0, | ||||
| 	Time:       time.Now(), | ||||
| 	Filesystem: "", | ||||
| } | ||||
|  | ||||
| var netIoStats = NetIoStats{ | ||||
| 	BytesRecv: 0, | ||||
| 	BytesSent: 0, | ||||
| 	Time:      time.Now(), | ||||
| 	Name:      "", | ||||
| } | ||||
|  | ||||
| // client for docker engine api | ||||
| var client = &http.Client{ | ||||
| 	Timeout: time.Second, | ||||
| 	Transport: &http.Transport{ | ||||
| 		Dial: func(proto, addr string) (net.Conn, error) { | ||||
| 			return net.Dial("unix", "/var/run/docker.sock") | ||||
| 		}, | ||||
| 		ForceAttemptHTTP2:   false, | ||||
| 		IdleConnTimeout:     90 * time.Second, | ||||
| 		DisableCompression:  true, | ||||
| 		MaxIdleConnsPerHost: 50, | ||||
| 		DisableKeepAlives:   false, | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| func getSystemStats() (*SystemInfo, *SystemStats) { | ||||
| 	c, _ := cpu.Percent(0, false) | ||||
| 	v, _ := mem.VirtualMemory() | ||||
| 	d, _ := disk.Usage("/") | ||||
|  | ||||
| 	cpuPct := twoDecimals(c[0]) | ||||
| 	memPct := twoDecimals(v.UsedPercent) | ||||
| 	diskPct := twoDecimals(d.UsedPercent) | ||||
|  | ||||
| 	systemStats := &SystemStats{ | ||||
| 		Cpu:          cpuPct, | ||||
| 		Mem:          bytesToGigabytes(v.Total), | ||||
| 		MemUsed:      bytesToGigabytes(v.Used), | ||||
| 		MemBuffCache: bytesToGigabytes(v.Total - v.Free - v.Used), | ||||
| 		MemPct:       memPct, | ||||
| 		Disk:         bytesToGigabytes(d.Total), | ||||
| 		DiskUsed:     bytesToGigabytes(d.Used), | ||||
| 		DiskPct:      diskPct, | ||||
| 	} | ||||
|  | ||||
| 	systemInfo := &SystemInfo{ | ||||
| 		Cpu:     cpuPct, | ||||
| 		MemPct:  memPct, | ||||
| 		DiskPct: diskPct, | ||||
| 	} | ||||
|  | ||||
| 	// add disk stats | ||||
| 	if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil { | ||||
| 		for _, d := range io { | ||||
| 			// add to systemStats | ||||
| 			secondsElapsed := time.Since(diskIoStats.Time).Seconds() | ||||
| 			readPerSecond := float64(d.ReadBytes-diskIoStats.Read) / secondsElapsed | ||||
| 			systemStats.DiskRead = bytesToMegabytes(readPerSecond) | ||||
| 			writePerSecond := float64(d.WriteBytes-diskIoStats.Write) / secondsElapsed | ||||
| 			systemStats.DiskWrite = bytesToMegabytes(writePerSecond) | ||||
| 			// update diskIoStats | ||||
| 			diskIoStats.Time = time.Now() | ||||
| 			diskIoStats.Read = d.ReadBytes | ||||
| 			diskIoStats.Write = d.WriteBytes | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// add network stats | ||||
| 	if netIO, err := psutilNet.IOCounters(true); err == nil { | ||||
| 		bytesSent := uint64(0) | ||||
| 		bytesRecv := uint64(0) | ||||
| 		for _, v := range netIO { | ||||
| 			if skipNetworkInterface(v.Name) { | ||||
| 				continue | ||||
| 			} | ||||
| 			// log.Printf("%+v: %+v recv, %+v sent\n", v.Name, v.BytesRecv, v.BytesSent) | ||||
| 			bytesSent += v.BytesSent | ||||
| 			bytesRecv += v.BytesRecv | ||||
| 		} | ||||
| 		// add to systemStats | ||||
| 		secondsElapsed := time.Since(netIoStats.Time).Seconds() | ||||
| 		sentPerSecond := float64(bytesSent-netIoStats.BytesSent) / secondsElapsed | ||||
| 		recvPerSecond := float64(bytesRecv-netIoStats.BytesRecv) / secondsElapsed | ||||
| 		systemStats.NetworkSent = bytesToMegabytes(sentPerSecond) | ||||
| 		systemStats.NetworkRecv = bytesToMegabytes(recvPerSecond) | ||||
| 		// update netIoStats | ||||
| 		netIoStats.BytesSent = bytesSent | ||||
| 		netIoStats.BytesRecv = bytesRecv | ||||
| 		netIoStats.Time = time.Now() | ||||
| 	} | ||||
|  | ||||
| 	// add host stats | ||||
| 	if info, err := host.Info(); err == nil { | ||||
| 		systemInfo.Uptime = info.Uptime | ||||
| 		// systemInfo.Os = info.OS | ||||
| 	} | ||||
| 	// add cpu stats | ||||
| 	if info, err := cpu.Info(); err == nil { | ||||
| 		systemInfo.CpuModel = info[0].ModelName | ||||
| 	} | ||||
| 	if cores, err := cpu.Counts(false); err == nil { | ||||
| 		systemInfo.Cores = cores | ||||
| 	} | ||||
| 	if threads, err := cpu.Counts(true); err == nil { | ||||
| 		systemInfo.Threads = threads | ||||
| 	} | ||||
|  | ||||
| 	return systemInfo, systemStats | ||||
|  | ||||
| } | ||||
|  | ||||
| func getDockerStats() ([]*ContainerStats, error) { | ||||
| 	resp, err := client.Get("http://localhost/containers/json") | ||||
| 	if err != nil { | ||||
| 		return []*ContainerStats{}, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	var containers []*Container | ||||
| 	if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
|  | ||||
| 	containerStats := make([]*ContainerStats, 0, len(containers)) | ||||
|  | ||||
| 	// store valid ids to clean up old container ids from map | ||||
| 	validIds := make(map[string]struct{}, len(containers)) | ||||
|  | ||||
| 	var wg sync.WaitGroup | ||||
|  | ||||
| 	for _, ctr := range containers { | ||||
| 		ctr.IdShort = ctr.ID[:12] | ||||
| 		validIds[ctr.IdShort] = struct{}{} | ||||
| 		wg.Add(1) | ||||
| 		go func() { | ||||
| 			defer wg.Done() | ||||
| 			cstats, err := getContainerStats(ctr) | ||||
| 			if err != nil { | ||||
| 				// retry once | ||||
| 				cstats, err = getContainerStats(ctr) | ||||
| 				if err != nil { | ||||
| 					log.Printf("Error getting container stats: %+v\n", err) | ||||
| 					return | ||||
| 				} | ||||
| 			} | ||||
| 			containerStats = append(containerStats, cstats) | ||||
| 		}() | ||||
| 	} | ||||
|  | ||||
| 	wg.Wait() | ||||
|  | ||||
| 	for id := range containerCpuMap { | ||||
| 		if _, exists := validIds[id]; !exists { | ||||
| 			// log.Printf("Removing container cpu map entry: %+v\n", id) | ||||
| 			delete(containerCpuMap, id) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return containerStats, nil | ||||
| } | ||||
|  | ||||
| func getContainerStats(ctr *Container) (*ContainerStats, error) { | ||||
| 	// use semaphore to limit concurrency | ||||
| 	acquireSemaphore() | ||||
| 	defer releaseSemaphore() | ||||
| 	resp, err := client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1") | ||||
| 	if err != nil { | ||||
| 		return &ContainerStats{}, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	var statsJson CStats | ||||
| 	if err := json.NewDecoder(resp.Body).Decode(&statsJson); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
|  | ||||
| 	name := ctr.Names[0][1:] | ||||
|  | ||||
| 	// memory (https://docs.docker.com/reference/cli/docker/container/stats/) | ||||
| 	memCache := statsJson.MemoryStats.Stats["inactive_file"] | ||||
| 	if memCache == 0 { | ||||
| 		memCache = statsJson.MemoryStats.Stats["cache"] | ||||
| 	} | ||||
| 	usedMemory := statsJson.MemoryStats.Usage - memCache | ||||
| 	// pctMemory := float64(usedMemory) / float64(statsJson.MemoryStats.Limit) * 100 | ||||
|  | ||||
| 	// cpu | ||||
| 	// add default values to containerCpu if it doesn't exist | ||||
| 	containerCpuMutex.Lock() | ||||
| 	defer containerCpuMutex.Unlock() | ||||
| 	if _, ok := containerCpuMap[ctr.IdShort]; !ok { | ||||
| 		containerCpuMap[ctr.IdShort] = [2]uint64{0, 0} | ||||
| 	} | ||||
| 	cpuDelta := statsJson.CPUStats.CPUUsage.TotalUsage - containerCpuMap[ctr.IdShort][0] | ||||
| 	systemDelta := statsJson.CPUStats.SystemUsage - containerCpuMap[ctr.IdShort][1] | ||||
| 	cpuPct := float64(cpuDelta) / float64(systemDelta) * 100 | ||||
| 	if cpuPct > 100 { | ||||
| 		return &ContainerStats{}, fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct) | ||||
| 	} | ||||
| 	containerCpuMap[ctr.IdShort] = [2]uint64{statsJson.CPUStats.CPUUsage.TotalUsage, statsJson.CPUStats.SystemUsage} | ||||
|  | ||||
| 	cStats := &ContainerStats{ | ||||
| 		Name: name, | ||||
| 		Cpu:  twoDecimals(cpuPct), | ||||
| 		Mem:  bytesToMegabytes(float64(usedMemory)), | ||||
| 		// MemPct: twoDecimals(pctMemory), | ||||
| 	} | ||||
| 	return cStats, nil | ||||
| } | ||||
|  | ||||
| func gatherStats() *SystemData { | ||||
| 	systemInfo, systemStats := getSystemStats() | ||||
| 	stats := &SystemData{ | ||||
| 		Stats:      systemStats, | ||||
| 		Info:       systemInfo, | ||||
| 		Containers: []*ContainerStats{}, | ||||
| 	} | ||||
| 	containerStats, err := getDockerStats() | ||||
| 	if err == nil { | ||||
| 		stats.Containers = containerStats | ||||
| 	} | ||||
| 	// fmt.Printf("%+v\n", stats) | ||||
| 	return stats | ||||
| } | ||||
|  | ||||
| func startServer(port string, pubKey []byte) { | ||||
| 	sshServer.Handle(func(s sshServer.Session) { | ||||
| 		stats := gatherStats() | ||||
| 		var jsonStats []byte | ||||
| 		jsonStats, _ = json.Marshal(stats) | ||||
| 		io.WriteString(s, string(jsonStats)) | ||||
| 		s.Exit(0) | ||||
| 	}) | ||||
|  | ||||
| 	log.Printf("Starting SSH server on port %s", port) | ||||
| 	if err := sshServer.ListenAndServe(":"+port, nil, sshServer.NoPty(), | ||||
| 		sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool { | ||||
| 			data := []byte(pubKey) | ||||
| 			allowed, _, _, _, _ := sshServer.ParseAuthorizedKey(data) | ||||
| 			return sshServer.KeysEqual(key, allowed) | ||||
| 		}), | ||||
| 	); err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func main() { | ||||
| 	// handle flags / subcommands | ||||
| 	if len(os.Args) > 1 { | ||||
| 		switch os.Args[1] { | ||||
| 		case "-v": | ||||
| 			fmt.Println("beszel-agent", Version) | ||||
| 		case "update": | ||||
| 			updateBeszel() | ||||
| 		} | ||||
| 		os.Exit(0) | ||||
| 	} | ||||
|  | ||||
| 	var pubKey []byte | ||||
| 	if pubKeyEnv, exists := os.LookupEnv("KEY"); exists { | ||||
| 		pubKey = []byte(pubKeyEnv) | ||||
| 	} else { | ||||
| 		log.Fatal("KEY environment variable is not set") | ||||
| 	} | ||||
|  | ||||
| 	if filesystem, exists := os.LookupEnv("FILESYSTEM"); exists { | ||||
| 		diskIoStats.Filesystem = filesystem | ||||
| 	} else { | ||||
| 		diskIoStats.Filesystem = findDefaultFilesystem() | ||||
| 	} | ||||
|  | ||||
| 	initializeDiskIoStats() | ||||
| 	initializeNetIoStats() | ||||
|  | ||||
| 	if port, exists := os.LookupEnv("PORT"); exists { | ||||
| 		startServer(port, pubKey) | ||||
| 	} else { | ||||
| 		startServer("45876", pubKey) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func bytesToMegabytes(b float64) float64 { | ||||
| 	return twoDecimals(b / 1048576) | ||||
| } | ||||
|  | ||||
| func bytesToGigabytes(b uint64) float64 { | ||||
| 	return twoDecimals(float64(b) / 1073741824) | ||||
| } | ||||
|  | ||||
| func twoDecimals(value float64) float64 { | ||||
| 	return math.Round(value*100) / 100 | ||||
| } | ||||
|  | ||||
| func findDefaultFilesystem() string { | ||||
| 	if partitions, err := disk.Partitions(false); err == nil { | ||||
| 		for _, v := range partitions { | ||||
| 			if v.Mountpoint == "/" { | ||||
| 				log.Printf("Using filesystem: %+v\n", v.Device) | ||||
| 				return v.Device | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return "" | ||||
| } | ||||
|  | ||||
| func skipNetworkInterface(name string) bool { | ||||
| 	return strings.HasPrefix(name, "lo") || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "veth") | ||||
| } | ||||
|  | ||||
| func initializeDiskIoStats() { | ||||
| 	if io, err := disk.IOCounters(diskIoStats.Filesystem); err == nil { | ||||
| 		for _, d := range io { | ||||
| 			diskIoStats.Time = time.Now() | ||||
| 			diskIoStats.Read = d.ReadBytes | ||||
| 			diskIoStats.Write = d.WriteBytes | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func initializeNetIoStats() { | ||||
| 	if netIO, err := psutilNet.IOCounters(true); err == nil { | ||||
| 		bytesSent := uint64(0) | ||||
| 		bytesRecv := uint64(0) | ||||
| 		for _, v := range netIO { | ||||
| 			if skipNetworkInterface(v.Name) { | ||||
| 				continue | ||||
| 			} | ||||
| 			log.Printf("Found network interface: %+v (%+v recv, %+v sent)\n", v.Name, v.BytesRecv, v.BytesSent) | ||||
| 			bytesSent += v.BytesSent | ||||
| 			bytesRecv += v.BytesRecv | ||||
| 		} | ||||
| 		netIoStats.BytesSent = bytesSent | ||||
| 		netIoStats.BytesRecv = bytesRecv | ||||
| 		netIoStats.Time = time.Now() | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										67
									
								
								agent/network.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,67 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"log/slog" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	psutilNet "github.com/shirou/gopsutil/v4/net" | ||||
| ) | ||||
|  | ||||
| func (a *Agent) initializeNetIoStats() { | ||||
| 	// reset valid network interfaces | ||||
| 	a.netInterfaces = make(map[string]struct{}, 0) | ||||
|  | ||||
| 	// map of network interface names passed in via NICS env var | ||||
| 	var nicsMap map[string]struct{} | ||||
| 	nics, nicsEnvExists := GetEnv("NICS") | ||||
| 	if nicsEnvExists { | ||||
| 		nicsMap = make(map[string]struct{}, 0) | ||||
| 		for nic := range strings.SplitSeq(nics, ",") { | ||||
| 			nicsMap[nic] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// reset network I/O stats | ||||
| 	a.netIoStats.BytesSent = 0 | ||||
| 	a.netIoStats.BytesRecv = 0 | ||||
|  | ||||
| 	// get intial network I/O stats | ||||
| 	if netIO, err := psutilNet.IOCounters(true); err == nil { | ||||
| 		a.netIoStats.Time = time.Now() | ||||
| 		for _, v := range netIO { | ||||
| 			switch { | ||||
| 			// skip if nics exists and the interface is not in the list | ||||
| 			case nicsEnvExists: | ||||
| 				if _, nameInNics := nicsMap[v.Name]; !nameInNics { | ||||
| 					continue | ||||
| 				} | ||||
| 			// otherwise run the interface name through the skipNetworkInterface function | ||||
| 			default: | ||||
| 				if a.skipNetworkInterface(v) { | ||||
| 					continue | ||||
| 				} | ||||
| 			} | ||||
| 			slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) | ||||
| 			a.netIoStats.BytesSent += v.BytesSent | ||||
| 			a.netIoStats.BytesRecv += v.BytesRecv | ||||
| 			// store as a valid network interface | ||||
| 			a.netInterfaces[v.Name] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool { | ||||
| 	switch { | ||||
| 	case strings.HasPrefix(v.Name, "lo"), | ||||
| 		strings.HasPrefix(v.Name, "docker"), | ||||
| 		strings.HasPrefix(v.Name, "br-"), | ||||
| 		strings.HasPrefix(v.Name, "veth"), | ||||
| 		strings.HasPrefix(v.Name, "bond"), | ||||
| 		v.BytesRecv == 0, | ||||
| 		v.BytesSent == 0: | ||||
| 		return true | ||||
| 	default: | ||||
| 		return false | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										198
									
								
								agent/sensors.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,198 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"path" | ||||
| 	"runtime" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"unicode/utf8" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/common" | ||||
| 	"github.com/shirou/gopsutil/v4/sensors" | ||||
| ) | ||||
|  | ||||
| type SensorConfig struct { | ||||
| 	context        context.Context | ||||
| 	sensors        map[string]struct{} | ||||
| 	primarySensor  string | ||||
| 	isBlacklist    bool | ||||
| 	hasWildcards   bool | ||||
| 	skipCollection bool | ||||
| } | ||||
|  | ||||
| func (a *Agent) newSensorConfig() *SensorConfig { | ||||
| 	primarySensor, _ := GetEnv("PRIMARY_SENSOR") | ||||
| 	sysSensors, _ := GetEnv("SYS_SENSORS") | ||||
| 	sensorsEnvVal, sensorsSet := GetEnv("SENSORS") | ||||
| 	skipCollection := sensorsSet && sensorsEnvVal == "" | ||||
|  | ||||
| 	return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection) | ||||
| } | ||||
|  | ||||
| // Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832) | ||||
| type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error) | ||||
|  | ||||
| // newSensorConfigWithEnv creates a SensorConfig with the provided environment variables | ||||
| // sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string) | ||||
| func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig { | ||||
| 	config := &SensorConfig{ | ||||
| 		context:        context.Background(), | ||||
| 		primarySensor:  primarySensor, | ||||
| 		skipCollection: skipCollection, | ||||
| 		sensors:        make(map[string]struct{}), | ||||
| 	} | ||||
|  | ||||
| 	// Set sensors context (allows overriding sys location for sensors) | ||||
| 	if sysSensors != "" { | ||||
| 		slog.Info("SYS_SENSORS", "path", sysSensors) | ||||
| 		config.context = context.WithValue(config.context, | ||||
| 			common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors}, | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	// handle blacklist | ||||
| 	if strings.HasPrefix(sensorsEnvVal, "-") { | ||||
| 		config.isBlacklist = true | ||||
| 		sensorsEnvVal = sensorsEnvVal[1:] | ||||
| 	} | ||||
|  | ||||
| 	for sensor := range strings.SplitSeq(sensorsEnvVal, ",") { | ||||
| 		sensor = strings.TrimSpace(sensor) | ||||
| 		if sensor != "" { | ||||
| 			config.sensors[sensor] = struct{}{} | ||||
| 			if strings.Contains(sensor, "*") { | ||||
| 				config.hasWildcards = true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return config | ||||
| } | ||||
|  | ||||
| // updateTemperatures updates the agent with the latest sensor temperatures | ||||
| func (a *Agent) updateTemperatures(systemStats *system.Stats) { | ||||
| 	// skip if sensors whitelist is set to empty string | ||||
| 	if a.sensorConfig.skipCollection { | ||||
| 		slog.Debug("Skipping temperature collection") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// reset high temp | ||||
| 	a.systemInfo.DashboardTemp = 0 | ||||
|  | ||||
| 	temps, err := a.getTempsWithPanicRecovery(getSensorTemps) | ||||
| 	if err != nil { | ||||
| 		// retry once on panic (gopsutil/issues/1832) | ||||
| 		temps, err = a.getTempsWithPanicRecovery(getSensorTemps) | ||||
| 		if err != nil { | ||||
| 			slog.Warn("Error updating temperatures", "err", err) | ||||
| 			if len(systemStats.Temperatures) > 0 { | ||||
| 				systemStats.Temperatures = make(map[string]float64) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 	slog.Debug("Temperature", "sensors", temps) | ||||
|  | ||||
| 	// return if no sensors | ||||
| 	if len(temps) == 0 { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	systemStats.Temperatures = make(map[string]float64, len(temps)) | ||||
| 	for i, sensor := range temps { | ||||
| 		// check for malformed strings on darwin (gopsutil/issues/1832) | ||||
| 		if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// scale temperature | ||||
| 		if sensor.Temperature != 0 && sensor.Temperature < 1 { | ||||
| 			sensor.Temperature = scaleTemperature(sensor.Temperature) | ||||
| 		} | ||||
| 		// 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 or blacklist | ||||
| 		if !isValidSensor(sensorName, a.sensorConfig) { | ||||
| 			continue | ||||
| 		} | ||||
| 		// set dashboard temperature | ||||
| 		switch a.sensorConfig.primarySensor { | ||||
| 		case "": | ||||
| 			a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature) | ||||
| 		case sensorName: | ||||
| 			a.systemInfo.DashboardTemp = sensor.Temperature | ||||
| 		} | ||||
| 		systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832) | ||||
| func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) { | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			err = fmt.Errorf("panic: %v", r) | ||||
| 		} | ||||
| 	}() | ||||
| 	// get sensor data (error ignored intentionally as it may be only with one sensor) | ||||
| 	temps, _ = getTemps(a.sensorConfig.context) | ||||
| 	return | ||||
| } | ||||
|  | ||||
| // isValidSensor checks if a sensor is valid based on the sensor name and the sensor config | ||||
| func isValidSensor(sensorName string, config *SensorConfig) bool { | ||||
| 	// if no sensors configured, everything is valid | ||||
| 	if len(config.sensors) == 0 { | ||||
| 		return true | ||||
| 	} | ||||
|  | ||||
| 	// Exact match - return true if whitelist, false if blacklist | ||||
| 	if _, exactMatch := config.sensors[sensorName]; exactMatch { | ||||
| 		return !config.isBlacklist | ||||
| 	} | ||||
|  | ||||
| 	// If no wildcards, return true if blacklist, false if whitelist | ||||
| 	if !config.hasWildcards { | ||||
| 		return config.isBlacklist | ||||
| 	} | ||||
|  | ||||
| 	// Check for wildcard patterns | ||||
| 	for pattern := range config.sensors { | ||||
| 		if !strings.Contains(pattern, "*") { | ||||
| 			continue | ||||
| 		} | ||||
| 		if match, _ := path.Match(pattern, sensorName); match { | ||||
| 			return !config.isBlacklist | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return config.isBlacklist | ||||
| } | ||||
|  | ||||
| // scaleTemperature scales temperatures in fractional values to reasonable Celsius values | ||||
| func scaleTemperature(temp float64) float64 { | ||||
| 	if temp > 1 { | ||||
| 		return temp | ||||
| 	} | ||||
| 	scaled100 := temp * 100 | ||||
| 	scaled1000 := temp * 1000 | ||||
|  | ||||
| 	if scaled100 >= 15 && scaled100 <= 95 { | ||||
| 		return scaled100 | ||||
| 	} else if scaled1000 >= 15 && scaled1000 <= 95 { | ||||
| 		return scaled1000 | ||||
| 	} | ||||
| 	return scaled100 | ||||
| } | ||||
							
								
								
									
										9
									
								
								agent/sensors_default.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| //go:build !windows | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"github.com/shirou/gopsutil/v4/sensors" | ||||
| ) | ||||
|  | ||||
| var getSensorTemps = sensors.TemperaturesWithContext | ||||
							
								
								
									
										554
									
								
								agent/sensors_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,554 @@ | ||||
| //go:build testing | ||||
| // +build testing | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/common" | ||||
| 	"github.com/shirou/gopsutil/v4/sensors" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestIsValidSensor(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name          string | ||||
| 		sensorName    string | ||||
| 		config        *SensorConfig | ||||
| 		expectedValid bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:       "Whitelist - sensor in list", | ||||
| 			sensorName: "cpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:     map[string]struct{}{"cpu_temp": {}}, | ||||
| 				isBlacklist: false, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Whitelist - sensor not in list", | ||||
| 			sensorName: "gpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:     map[string]struct{}{"cpu_temp": {}}, | ||||
| 				isBlacklist: false, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Blacklist - sensor in list", | ||||
| 			sensorName: "cpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:     map[string]struct{}{"cpu_temp": {}}, | ||||
| 				isBlacklist: true, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Blacklist - sensor not in list", | ||||
| 			sensorName: "gpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:     map[string]struct{}{"cpu_temp": {}}, | ||||
| 				isBlacklist: true, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Whitelist with wildcard - matching pattern", | ||||
| 			sensorName: "core_0_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"core_*_temp": {}}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Whitelist with wildcard - non-matching pattern", | ||||
| 			sensorName: "gpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"core_*_temp": {}}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Blacklist with wildcard - matching pattern", | ||||
| 			sensorName: "core_0_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"core_*_temp": {}}, | ||||
| 				isBlacklist:  true, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Blacklist with wildcard - non-matching pattern", | ||||
| 			sensorName: "gpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"core_*_temp": {}}, | ||||
| 				isBlacklist:  true, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "No sensors configured", | ||||
| 			sensorName: "any_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:        map[string]struct{}{}, | ||||
| 				isBlacklist:    false, | ||||
| 				hasWildcards:   false, | ||||
| 				skipCollection: false, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Mixed patterns in whitelist - exact match", | ||||
| 			sensorName: "cpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Mixed patterns in whitelist - wildcard match", | ||||
| 			sensorName: "core_1_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Mixed patterns in blacklist - exact match", | ||||
| 			sensorName: "cpu_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, | ||||
| 				isBlacklist:  true, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "Mixed patterns in blacklist - wildcard match", | ||||
| 			sensorName: "core_1_temp", | ||||
| 			config: &SensorConfig{ | ||||
| 				sensors:      map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, | ||||
| 				isBlacklist:  true, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 			expectedValid: false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result := isValidSensor(tt.sensorName, tt.config) | ||||
| 			assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNewSensorConfigWithEnv(t *testing.T) { | ||||
| 	agent := &Agent{} | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name           string | ||||
| 		primarySensor  string | ||||
| 		sysSensors     string | ||||
| 		sensors        string | ||||
| 		skipCollection bool | ||||
| 		expectedConfig *SensorConfig | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:          "Empty configuration", | ||||
| 			primarySensor: "", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:        context.Background(), | ||||
| 				primarySensor:  "", | ||||
| 				sensors:        map[string]struct{}{}, | ||||
| 				isBlacklist:    false, | ||||
| 				hasWildcards:   false, | ||||
| 				skipCollection: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:           "Explicitly set to empty string", | ||||
| 			primarySensor:  "", | ||||
| 			sysSensors:     "", | ||||
| 			sensors:        "", | ||||
| 			skipCollection: true, | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:        context.Background(), | ||||
| 				primarySensor:  "", | ||||
| 				sensors:        map[string]struct{}{}, | ||||
| 				isBlacklist:    false, | ||||
| 				hasWildcards:   false, | ||||
| 				skipCollection: true, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Primary sensor only - should create sensor map", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:       context.Background(), | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors:       map[string]struct{}{}, | ||||
| 				isBlacklist:   false, | ||||
| 				hasWildcards:  false, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Whitelist sensors", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "cpu_temp,gpu_temp", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:       context.Background(), | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors: map[string]struct{}{ | ||||
| 					"cpu_temp": {}, | ||||
| 					"gpu_temp": {}, | ||||
| 				}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Blacklist sensors", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "-cpu_temp,gpu_temp", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:       context.Background(), | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors: map[string]struct{}{ | ||||
| 					"cpu_temp": {}, | ||||
| 					"gpu_temp": {}, | ||||
| 				}, | ||||
| 				isBlacklist:  true, | ||||
| 				hasWildcards: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Sensors with wildcard", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "cpu_*,gpu_temp", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:       context.Background(), | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors: map[string]struct{}{ | ||||
| 					"cpu_*":    {}, | ||||
| 					"gpu_temp": {}, | ||||
| 				}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "Sensors with whitespace", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "", | ||||
| 			sensors:       "cpu_*, gpu_temp", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				context:       context.Background(), | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors: map[string]struct{}{ | ||||
| 					"cpu_*":    {}, | ||||
| 					"gpu_temp": {}, | ||||
| 				}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: true, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "With SYS_SENSORS path", | ||||
| 			primarySensor: "cpu_temp", | ||||
| 			sysSensors:    "/custom/path", | ||||
| 			sensors:       "cpu_temp", | ||||
| 			expectedConfig: &SensorConfig{ | ||||
| 				primarySensor: "cpu_temp", | ||||
| 				sensors: map[string]struct{}{ | ||||
| 					"cpu_temp": {}, | ||||
| 				}, | ||||
| 				isBlacklist:  false, | ||||
| 				hasWildcards: false, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection) | ||||
|  | ||||
| 			// Check primary sensor | ||||
| 			assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) | ||||
|  | ||||
| 			// Check sensor map | ||||
| 			if tt.expectedConfig.sensors == nil { | ||||
| 				assert.Nil(t, result.sensors) | ||||
| 			} else { | ||||
| 				assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors)) | ||||
| 				for sensor := range tt.expectedConfig.sensors { | ||||
| 					_, exists := result.sensors[sensor] | ||||
| 					assert.True(t, exists, "Sensor %s should exist in the result", sensor) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// Check flags | ||||
| 			assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist) | ||||
| 			assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards) | ||||
|  | ||||
| 			// Check context | ||||
| 			if tt.sysSensors != "" { | ||||
| 				// Verify context contains correct values | ||||
| 				envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap) | ||||
| 				require.True(t, ok, "Context should contain EnvMap") | ||||
| 				sysPath, ok := envMap[common.HostSysEnvKey] | ||||
| 				require.True(t, ok, "EnvMap should contain HostSysEnvKey") | ||||
| 				assert.Equal(t, tt.sysSensors, sysPath) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNewSensorConfig(t *testing.T) { | ||||
| 	// Save original environment variables | ||||
| 	originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR") | ||||
| 	originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS") | ||||
| 	originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS") | ||||
|  | ||||
| 	// Restore environment variables after the test | ||||
| 	defer func() { | ||||
| 		// Clean up test environment variables | ||||
| 		os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR") | ||||
| 		os.Unsetenv("BESZEL_AGENT_SYS_SENSORS") | ||||
| 		os.Unsetenv("BESZEL_AGENT_SENSORS") | ||||
|  | ||||
| 		// Restore original values if they existed | ||||
| 		if hasPrimary { | ||||
| 			os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary) | ||||
| 		} | ||||
| 		if hasSys { | ||||
| 			os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys) | ||||
| 		} | ||||
| 		if hasSensors { | ||||
| 			os.Setenv("BESZEL_AGENT_SENSORS", originalSensors) | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	// Set test environment variables | ||||
| 	os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary") | ||||
| 	os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path") | ||||
| 	os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3") | ||||
|  | ||||
| 	agent := &Agent{} | ||||
| 	result := agent.newSensorConfig() | ||||
|  | ||||
| 	// Verify results | ||||
| 	assert.Equal(t, "test_primary", result.primarySensor) | ||||
| 	assert.NotNil(t, result.sensors) | ||||
| 	assert.Equal(t, 3, len(result.sensors)) | ||||
| 	assert.True(t, result.hasWildcards) | ||||
| 	assert.False(t, result.isBlacklist) | ||||
|  | ||||
| 	// Check that sys sensors path is in context | ||||
| 	envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap) | ||||
| 	require.True(t, ok, "Context should contain EnvMap") | ||||
| 	sysPath, ok := envMap[common.HostSysEnvKey] | ||||
| 	require.True(t, ok, "EnvMap should contain HostSysEnvKey") | ||||
| 	assert.Equal(t, "/test/path", sysPath) | ||||
| } | ||||
|  | ||||
| func TestScaleTemperature(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		input    float64 | ||||
| 		expected float64 | ||||
| 		desc     string | ||||
| 	}{ | ||||
| 		// Normal temperatures (no scaling needed) | ||||
| 		{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"}, | ||||
| 		{"normal_room_temp", 25.0, 25.0, "Normal room temperature"}, | ||||
| 		{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"}, | ||||
| 		// Zero temperature | ||||
| 		{"zero_temp", 0.0, 0.0, "Zero temperature"}, | ||||
| 		// Fractional values that should use 100x scaling | ||||
| 		{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"}, | ||||
| 		{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"}, | ||||
| 		{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"}, | ||||
| 		{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"}, | ||||
| 		{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"}, | ||||
| 		// Fractional values that should use 1000x scaling | ||||
| 		{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"}, | ||||
| 		{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"}, | ||||
| 		{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"}, | ||||
| 		{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"}, | ||||
| 		{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"}, | ||||
| 		// Edge cases - values outside reasonable range | ||||
| 		{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"}, | ||||
| 		{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"}, | ||||
| 		{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"}, | ||||
| 		// Boundary cases around the reasonable range (15-95°C) | ||||
| 		{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"}, | ||||
| 		{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"}, | ||||
| 		{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"}, | ||||
| 		{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"}, | ||||
| 		// Values just outside reasonable range | ||||
| 		{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"}, | ||||
| 		{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result := scaleTemperature(tt.input) | ||||
| 			assert.InDelta(t, tt.expected, result, 0.001, | ||||
| 				"scaleTemperature(%v) = %v, expected %v (%s)", | ||||
| 				tt.input, result, tt.expected, tt.desc) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestScaleTemperatureLogic(t *testing.T) { | ||||
| 	// Test the logic flow for ambiguous cases | ||||
| 	t.Run("prefers_100x_when_both_valid", func(t *testing.T) { | ||||
| 		// 0.5 could be 50°C (100x) or 500°C (1000x) | ||||
| 		// Should prefer 100x since it's tried first and is in range | ||||
| 		result := scaleTemperature(0.5) | ||||
| 		expected := 50.0 | ||||
| 		assert.InDelta(t, expected, result, 0.001, | ||||
| 			"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)", | ||||
| 			result, expected) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) { | ||||
| 		// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range) | ||||
| 		// Should use 1000x since 100x is below reasonable range | ||||
| 		result := scaleTemperature(0.05) | ||||
| 		expected := 50.0 | ||||
| 		assert.InDelta(t, expected, result, 0.001, | ||||
| 			"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)", | ||||
| 			result, expected) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) { | ||||
| 		// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low) | ||||
| 		// Should default to 100x scaling | ||||
| 		result := scaleTemperature(0.005) | ||||
| 		expected := 0.5 | ||||
| 		assert.InDelta(t, expected, result, 0.001, | ||||
| 			"scaleTemperature(0.005) = %v, expected %v (should default to 100x)", | ||||
| 			result, expected) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestGetTempsWithPanicRecovery(t *testing.T) { | ||||
| 	agent := &Agent{ | ||||
| 		systemInfo: system.Info{}, | ||||
| 		sensorConfig: &SensorConfig{ | ||||
| 			context: context.Background(), | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name        string | ||||
| 		getTempsFn  getTempsFn | ||||
| 		expectError bool | ||||
| 		errorMsg    string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "successful_function_call", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				return []sensors.TemperatureStat{ | ||||
| 					{SensorKey: "test_sensor", Temperature: 45.0}, | ||||
| 				}, nil | ||||
| 			}, | ||||
| 			expectError: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "function_returns_error", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				return []sensors.TemperatureStat{ | ||||
| 					{SensorKey: "test_sensor", Temperature: 45.0}, | ||||
| 				}, fmt.Errorf("sensor error") | ||||
| 			}, | ||||
| 			expectError: false, // getTempsWithPanicRecovery ignores errors from the function | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "function_panics_with_string", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				panic("test panic") | ||||
| 			}, | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "panic: test panic", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "function_panics_with_error", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				panic(fmt.Errorf("panic error")) | ||||
| 			}, | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "panic:", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "function_panics_with_index_out_of_bounds", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				slice := []int{1, 2, 3} | ||||
| 				_ = slice[10] // out of bounds panic | ||||
| 				return nil, nil | ||||
| 			}, | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "panic:", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "function_panics_with_any_conversion", | ||||
| 			getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { | ||||
| 				var i any = "string" | ||||
| 				_ = i.(int) // type assertion panic | ||||
| 				return nil, nil | ||||
| 			}, | ||||
| 			expectError: true, | ||||
| 			errorMsg:    "panic:", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			var temps []sensors.TemperatureStat | ||||
| 			var err error | ||||
|  | ||||
| 			// The function should not panic, regardless of what the injected function does | ||||
| 			assert.NotPanics(t, func() { | ||||
| 				temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn) | ||||
| 			}, "getTempsWithPanicRecovery should not panic") | ||||
|  | ||||
| 			if tt.expectError { | ||||
| 				assert.Error(t, err, "Expected an error to be returned") | ||||
| 				if tt.errorMsg != "" { | ||||
| 					assert.Contains(t, err.Error(), tt.errorMsg, | ||||
| 						"Error message should contain expected text") | ||||
| 				} | ||||
| 				assert.Nil(t, temps, "Temps should be nil when panic occurs") | ||||
| 			} else { | ||||
| 				assert.NoError(t, err, "Should not return error for successful calls") | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										286
									
								
								agent/sensors_windows.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,286 @@ | ||||
| //go:build windows | ||||
|  | ||||
| //go:generate dotnet build -c Release lhm/beszel_lhm.csproj | ||||
|  | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"context" | ||||
| 	"embed" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/sensors" | ||||
| ) | ||||
|  | ||||
| // Note: This is always called from Agent.gatherStats() which holds Agent.Lock(), | ||||
| // so no internal concurrency protection is needed. | ||||
|  | ||||
| // lhmProcess is a wrapper around the LHM .NET process. | ||||
| type lhmProcess struct { | ||||
| 	cmd                  *exec.Cmd | ||||
| 	stdin                io.WriteCloser | ||||
| 	stdout               io.ReadCloser | ||||
| 	scanner              *bufio.Scanner | ||||
| 	isRunning            bool | ||||
| 	stoppedNoSensors     bool | ||||
| 	consecutiveNoSensors uint8 | ||||
| 	execPath             string | ||||
| 	tempDir              string | ||||
| } | ||||
|  | ||||
| //go:embed all:lhm/bin/Release/net48 | ||||
| var lhmFs embed.FS | ||||
|  | ||||
| var ( | ||||
| 	beszelLhm     *lhmProcess | ||||
| 	beszelLhmOnce sync.Once | ||||
| 	useLHM        = os.Getenv("LHM") == "true" | ||||
| ) | ||||
|  | ||||
| var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)") | ||||
|  | ||||
| // newlhmProcess copies the embedded LHM executable to a temporary directory and starts it. | ||||
| func newlhmProcess() (*lhmProcess, error) { | ||||
| 	destDir := filepath.Join(os.TempDir(), "beszel") | ||||
| 	execPath := filepath.Join(destDir, "beszel_lhm.exe") | ||||
|  | ||||
| 	if err := os.MkdirAll(destDir, 0755); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to create temp directory: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Only copy if executable doesn't exist | ||||
| 	if _, err := os.Stat(execPath); os.IsNotExist(err) { | ||||
| 		if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil { | ||||
| 			return nil, fmt.Errorf("failed to copy embedded directory: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	lhm := &lhmProcess{ | ||||
| 		execPath: execPath, | ||||
| 		tempDir:  destDir, | ||||
| 	} | ||||
|  | ||||
| 	if err := lhm.startProcess(); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to start process: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return lhm, nil | ||||
| } | ||||
|  | ||||
| // startProcess starts the external LHM process | ||||
| func (lhm *lhmProcess) startProcess() error { | ||||
| 	// Clean up any existing process | ||||
| 	lhm.cleanupProcess() | ||||
|  | ||||
| 	cmd := exec.Command(lhm.execPath) | ||||
| 	stdin, err := cmd.StdinPipe() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	stdout, err := cmd.StdoutPipe() | ||||
| 	if err != nil { | ||||
| 		stdin.Close() | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := cmd.Start(); err != nil { | ||||
| 		stdin.Close() | ||||
| 		stdout.Close() | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Update process state | ||||
| 	lhm.cmd = cmd | ||||
| 	lhm.stdin = stdin | ||||
| 	lhm.stdout = stdout | ||||
| 	lhm.scanner = bufio.NewScanner(stdout) | ||||
| 	lhm.isRunning = true | ||||
|  | ||||
| 	// Give process a moment to initialize | ||||
| 	time.Sleep(100 * time.Millisecond) | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // cleanupProcess terminates the process and closes resources but preserves files | ||||
| func (lhm *lhmProcess) cleanupProcess() { | ||||
| 	lhm.isRunning = false | ||||
|  | ||||
| 	if lhm.cmd != nil && lhm.cmd.Process != nil { | ||||
| 		lhm.cmd.Process.Kill() | ||||
| 		lhm.cmd.Wait() | ||||
| 	} | ||||
|  | ||||
| 	if lhm.stdin != nil { | ||||
| 		lhm.stdin.Close() | ||||
| 		lhm.stdin = nil | ||||
| 	} | ||||
| 	if lhm.stdout != nil { | ||||
| 		lhm.stdout.Close() | ||||
| 		lhm.stdout = nil | ||||
| 	} | ||||
|  | ||||
| 	lhm.cmd = nil | ||||
| 	lhm.scanner = nil | ||||
| 	lhm.stoppedNoSensors = false | ||||
| 	lhm.consecutiveNoSensors = 0 | ||||
| } | ||||
|  | ||||
| func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { | ||||
| 	if !useLHM || lhm.stoppedNoSensors { | ||||
| 		// Fall back to gopsutil if we can't get sensors from LHM | ||||
| 		return sensors.TemperaturesWithContext(ctx) | ||||
| 	} | ||||
|  | ||||
| 	// Start process if it's not running | ||||
| 	if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil { | ||||
| 		err := lhm.startProcess() | ||||
| 		if err != nil { | ||||
| 			return temps, err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Send command to process | ||||
| 	_, err = fmt.Fprintln(lhm.stdin, "getTemps") | ||||
| 	if err != nil { | ||||
| 		lhm.isRunning = false | ||||
| 		return temps, fmt.Errorf("failed to send command: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Read all sensor lines until we hit an empty line or EOF | ||||
| 	for lhm.scanner.Scan() { | ||||
| 		line := strings.TrimSpace(lhm.scanner.Text()) | ||||
| 		if line == "" { | ||||
| 			break | ||||
| 		} | ||||
|  | ||||
| 		parts := strings.Split(line, "|") | ||||
| 		if len(parts) != 2 { | ||||
| 			slog.Debug("Invalid sensor format", "line", line) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		name := strings.TrimSpace(parts[0]) | ||||
| 		valueStr := strings.TrimSpace(parts[1]) | ||||
|  | ||||
| 		value, err := strconv.ParseFloat(valueStr, 64) | ||||
| 		if err != nil { | ||||
| 			slog.Debug("Failed to parse sensor", "err", err, "line", line) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if name == "" || value <= 0 || value > 150 { | ||||
| 			slog.Debug("Invalid sensor", "name", name, "val", value, "line", line) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		temps = append(temps, sensors.TemperatureStat{ | ||||
| 			SensorKey:   name, | ||||
| 			Temperature: value, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	if err := lhm.scanner.Err(); err != nil { | ||||
| 		lhm.isRunning = false | ||||
| 		return temps, err | ||||
| 	} | ||||
|  | ||||
| 	// Handle no sensors case | ||||
| 	if len(temps) == 0 { | ||||
| 		lhm.consecutiveNoSensors++ | ||||
| 		if lhm.consecutiveNoSensors >= 3 { | ||||
| 			lhm.stoppedNoSensors = true | ||||
| 			slog.Warn(errNoSensors.Error()) | ||||
| 			lhm.cleanup() | ||||
| 		} | ||||
| 		return sensors.TemperaturesWithContext(ctx) | ||||
| 	} | ||||
|  | ||||
| 	lhm.consecutiveNoSensors = 0 | ||||
|  | ||||
| 	return temps, nil | ||||
| } | ||||
|  | ||||
| // getSensorTemps attempts to pull sensor temperatures from the embedded LHM process. | ||||
| // NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors. | ||||
| func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { | ||||
| 	defer func() { | ||||
| 		if err != nil { | ||||
| 			slog.Debug("Error reading sensors", "err", err) | ||||
| 		} | ||||
| 	}() | ||||
|  | ||||
| 	if !useLHM { | ||||
| 		return sensors.TemperaturesWithContext(ctx) | ||||
| 	} | ||||
|  | ||||
| 	// Initialize process once | ||||
| 	beszelLhmOnce.Do(func() { | ||||
| 		beszelLhm, err = newlhmProcess() | ||||
| 	}) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return temps, fmt.Errorf("failed to initialize lhm: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if beszelLhm == nil { | ||||
| 		return temps, fmt.Errorf("lhm not available") | ||||
| 	} | ||||
|  | ||||
| 	return beszelLhm.getTemps(ctx) | ||||
| } | ||||
|  | ||||
| // cleanup terminates the process and closes resources | ||||
| func (lhm *lhmProcess) cleanup() { | ||||
| 	lhm.cleanupProcess() | ||||
| 	if lhm.tempDir != "" { | ||||
| 		os.RemoveAll(lhm.tempDir) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // copyEmbeddedDir copies the embedded directory to the destination path | ||||
| func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error { | ||||
| 	entries, err := fs.ReadDir(srcPath) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := os.MkdirAll(destPath, 0755); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	for _, entry := range entries { | ||||
| 		srcEntryPath := path.Join(srcPath, entry.Name()) | ||||
| 		destEntryPath := filepath.Join(destPath, entry.Name()) | ||||
|  | ||||
| 		if entry.IsDir() { | ||||
| 			if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		data, err := fs.ReadFile(srcEntryPath) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if err := os.WriteFile(destEntryPath, data, 0755); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										224
									
								
								agent/server.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,224 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"log/slog" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel" | ||||
| 	"github.com/henrygd/beszel/internal/common" | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/blang/semver" | ||||
| 	"github.com/fxamacker/cbor/v2" | ||||
| 	"github.com/gliderlabs/ssh" | ||||
| 	gossh "golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| // ServerOptions contains configuration options for starting the SSH server. | ||||
| type ServerOptions struct { | ||||
| 	Addr    string            // Network address to listen on (e.g., ":45876" or "/path/to/socket") | ||||
| 	Network string            // Network type ("tcp" or "unix") | ||||
| 	Keys    []gossh.PublicKey // SSH public keys for authentication | ||||
| } | ||||
|  | ||||
| // hubVersions caches hub versions by session ID to avoid repeated parsing. | ||||
| var hubVersions map[string]semver.Version | ||||
|  | ||||
| // StartServer starts the SSH server with the provided options. | ||||
| // It configures the server with secure defaults, sets up authentication, | ||||
| // and begins listening for connections. Returns an error if the server | ||||
| // is already running or if there's an issue starting the server. | ||||
| func (a *Agent) StartServer(opts ServerOptions) error { | ||||
| 	if a.server != nil { | ||||
| 		return errors.New("server already started") | ||||
| 	} | ||||
|  | ||||
| 	slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network) | ||||
|  | ||||
| 	if opts.Network == "unix" { | ||||
| 		// remove existing socket file if it exists | ||||
| 		if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// start listening on the address | ||||
| 	ln, err := net.Listen(opts.Network, opts.Addr) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer ln.Close() | ||||
|  | ||||
| 	// base config (limit to allowed algorithms) | ||||
| 	config := &gossh.ServerConfig{ | ||||
| 		ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), | ||||
| 	} | ||||
| 	config.KeyExchanges = common.DefaultKeyExchanges | ||||
| 	config.MACs = common.DefaultMACs | ||||
| 	config.Ciphers = common.DefaultCiphers | ||||
|  | ||||
| 	// set default handler | ||||
| 	ssh.Handle(a.handleSession) | ||||
|  | ||||
| 	a.server = &ssh.Server{ | ||||
| 		ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig { | ||||
| 			return config | ||||
| 		}, | ||||
| 		// check public key(s) | ||||
| 		PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { | ||||
| 			remoteAddr := ctx.RemoteAddr() | ||||
| 			for _, pubKey := range opts.Keys { | ||||
| 				if ssh.KeysEqual(key, pubKey) { | ||||
| 					slog.Info("SSH connected", "addr", remoteAddr) | ||||
| 					return true | ||||
| 				} | ||||
| 			} | ||||
| 			slog.Warn("Invalid SSH key", "addr", remoteAddr) | ||||
| 			return false | ||||
| 		}, | ||||
| 		// disable pty | ||||
| 		PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool { | ||||
| 			return false | ||||
| 		}, | ||||
| 		// close idle connections after 70 seconds | ||||
| 		IdleTimeout: 70 * time.Second, | ||||
| 	} | ||||
|  | ||||
| 	// Start SSH server on the listener | ||||
| 	return a.server.Serve(ln) | ||||
| } | ||||
|  | ||||
| // getHubVersion retrieves and caches the hub version for a given session. | ||||
| // It extracts the version from the SSH client version string and caches | ||||
| // it to avoid repeated parsing. Returns a zero version if parsing fails. | ||||
| func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version { | ||||
| 	if hubVersions == nil { | ||||
| 		hubVersions = make(map[string]semver.Version, 1) | ||||
| 	} | ||||
| 	hubVersion, ok := hubVersions[sessionId] | ||||
| 	if ok { | ||||
| 		return hubVersion | ||||
| 	} | ||||
| 	// Extract hub version from SSH client version | ||||
| 	clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) | ||||
| 	if versionStr, ok := clientVersion.(string); ok { | ||||
| 		hubVersion, _ = extractHubVersion(versionStr) | ||||
| 	} | ||||
| 	hubVersions[sessionId] = hubVersion | ||||
| 	return hubVersion | ||||
| } | ||||
|  | ||||
| // handleSession handles an incoming SSH session by gathering system statistics | ||||
| // and sending them to the hub. It signals connection events, determines the | ||||
| // appropriate encoding format based on hub version, and exits with appropriate | ||||
| // status codes. | ||||
| func (a *Agent) handleSession(s ssh.Session) { | ||||
| 	a.connectionManager.eventChan <- SSHConnect | ||||
|  | ||||
| 	sessionCtx := s.Context() | ||||
| 	sessionID := sessionCtx.SessionID() | ||||
|  | ||||
| 	hubVersion := a.getHubVersion(sessionID, sessionCtx) | ||||
|  | ||||
| 	stats := a.gatherStats(sessionID) | ||||
|  | ||||
| 	err := a.writeToSession(s, stats, hubVersion) | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error encoding stats", "err", err, "stats", stats) | ||||
| 		s.Exit(1) | ||||
| 	} else { | ||||
| 		s.Exit(0) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // writeToSession encodes and writes system statistics to the session. | ||||
| // It chooses between CBOR and JSON encoding based on the hub version, | ||||
| // using CBOR for newer versions and JSON for legacy compatibility. | ||||
| func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersion semver.Version) error { | ||||
| 	if hubVersion.GTE(beszel.MinVersionCbor) { | ||||
| 		return cbor.NewEncoder(w).Encode(stats) | ||||
| 	} | ||||
| 	return json.NewEncoder(w).Encode(stats) | ||||
| } | ||||
|  | ||||
| // extractHubVersion extracts the beszel version from SSH client version string. | ||||
| // Expected format: "SSH-2.0-beszel_X.Y.Z" or "beszel_X.Y.Z" | ||||
| func extractHubVersion(versionString string) (semver.Version, error) { | ||||
| 	_, after, _ := strings.Cut(versionString, "_") | ||||
| 	return semver.Parse(after) | ||||
| } | ||||
|  | ||||
| // 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) ([]gossh.PublicKey, error) { | ||||
| 	var parsedKeys []gossh.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 := gossh.ParseAuthorizedKey([]byte(line)) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err) | ||||
| 		} | ||||
| 		parsedKeys = append(parsedKeys, parsedKey) | ||||
| 	} | ||||
| 	return parsedKeys, nil | ||||
| } | ||||
|  | ||||
| // GetAddress determines the network address to listen on from various sources. | ||||
| // It checks the provided address, then environment variables (LISTEN, PORT), | ||||
| // and finally defaults to ":45876". | ||||
| func GetAddress(addr string) string { | ||||
| 	if addr == "" { | ||||
| 		addr, _ = GetEnv("LISTEN") | ||||
| 	} | ||||
| 	if addr == "" { | ||||
| 		// Legacy PORT environment variable support | ||||
| 		addr, _ = GetEnv("PORT") | ||||
| 	} | ||||
| 	if addr == "" { | ||||
| 		return ":45876" | ||||
| 	} | ||||
| 	// prefix with : if only port was provided | ||||
| 	if GetNetwork(addr) != "unix" && !strings.Contains(addr, ":") { | ||||
| 		addr = ":" + addr | ||||
| 	} | ||||
| 	return addr | ||||
| } | ||||
|  | ||||
| // GetNetwork determines the network type based on the address format. | ||||
| // It checks the NETWORK environment variable first, then infers from | ||||
| // the address format: addresses starting with "/" are "unix", others are "tcp". | ||||
| func GetNetwork(addr string) string { | ||||
| 	if network, ok := GetEnv("NETWORK"); ok && network != "" { | ||||
| 		return network | ||||
| 	} | ||||
| 	if strings.HasPrefix(addr, "/") { | ||||
| 		return "unix" | ||||
| 	} | ||||
| 	return "tcp" | ||||
| } | ||||
|  | ||||
| // StopServer stops the SSH server if it's running. | ||||
| // It returns an error if the server is not running or if there's an error stopping it. | ||||
| func (a *Agent) StopServer() error { | ||||
| 	if a.server == nil { | ||||
| 		return errors.New("SSH server not running") | ||||
| 	} | ||||
|  | ||||
| 	slog.Info("Stopping SSH server") | ||||
| 	_ = a.server.Close() | ||||
| 	a.server = nil | ||||
| 	a.connectionManager.eventChan <- SSHDisconnect | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										606
									
								
								agent/server_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,606 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/ed25519" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel/internal/entities/container" | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/blang/semver" | ||||
| 	"github.com/fxamacker/cbor/v2" | ||||
| 	"github.com/gliderlabs/ssh" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	gossh "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 := gossh.NewSignerFromKey(privKey) | ||||
| 	require.NoError(t, err) | ||||
| 	sshPubKey, err := gossh.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 := gossh.NewSignerFromKey(badPrivKey) | ||||
| 	require.NoError(t, err) | ||||
| 	sshBadPubKey, err := gossh.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:    []gossh.PublicKey{sshPubKey}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "tcp with ipv4", | ||||
| 			config: ServerOptions{ | ||||
| 				Network: "tcp4", | ||||
| 				Addr:    "127.0.0.1:45988", | ||||
| 				Keys:    []gossh.PublicKey{sshPubKey}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "tcp with ipv6", | ||||
| 			config: ServerOptions{ | ||||
| 				Network: "tcp6", | ||||
| 				Addr:    "[::1]:45989", | ||||
| 				Keys:    []gossh.PublicKey{sshPubKey}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "unix socket", | ||||
| 			config: ServerOptions{ | ||||
| 				Network: "unix", | ||||
| 				Addr:    socketFile, | ||||
| 				Keys:    []gossh.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:    []gossh.PublicKey{sshBadPubKey}, | ||||
| 			}, | ||||
| 			wantErr:     true, | ||||
| 			errContains: "ssh: handshake failed", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "good key still good", | ||||
| 			config: ServerOptions{ | ||||
| 				Network: "tcp", | ||||
| 				Addr:    ":45987", | ||||
| 				Keys:    []gossh.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, err := NewAgent("") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			// 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 *gossh.Client | ||||
|  | ||||
| 			// Choose the appropriate signer based on the test case | ||||
| 			testSigner := signer | ||||
| 			if tt.name == "bad key should fail" { | ||||
| 				testSigner = badSigner | ||||
| 			} | ||||
|  | ||||
| 			sshClientConfig := &gossh.ClientConfig{ | ||||
| 				User: "a", | ||||
| 				Auth: []gossh.AuthMethod{ | ||||
| 					gossh.PublicKeys(testSigner), | ||||
| 				}, | ||||
| 				HostKeyCallback: gossh.InsecureIgnoreHostKey(), | ||||
| 				Timeout:         4 * time.Second, | ||||
| 			} | ||||
|  | ||||
| 			switch tt.config.Network { | ||||
| 			case "unix": | ||||
| 				client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig) | ||||
| 			default: | ||||
| 				if !strings.Contains(tt.config.Addr, ":") { | ||||
| 					tt.config.Addr = ":" + tt.config.Addr | ||||
| 				} | ||||
| 				client, err = gossh.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) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| ///////////////////////////////////////////////////////////////// | ||||
| //////////////////// Hub Version Tests ////////////////////////// | ||||
| ///////////////////////////////////////////////////////////////// | ||||
|  | ||||
| func TestExtractHubVersion(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name            string | ||||
| 		clientVersion   string | ||||
| 		expectedVersion string | ||||
| 		expectError     bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:            "valid beszel client version with underscore", | ||||
| 			clientVersion:   "SSH-2.0-beszel_0.11.1", | ||||
| 			expectedVersion: "0.11.1", | ||||
| 			expectError:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:            "valid beszel client version with beta", | ||||
| 			clientVersion:   "SSH-2.0-beszel_1.0.0-beta", | ||||
| 			expectedVersion: "1.0.0-beta", | ||||
| 			expectError:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:            "valid beszel client version with rc", | ||||
| 			clientVersion:   "SSH-2.0-beszel_0.12.0-rc1", | ||||
| 			expectedVersion: "0.12.0-rc1", | ||||
| 			expectError:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:            "different SSH client", | ||||
| 			clientVersion:   "SSH-2.0-OpenSSH_8.0", | ||||
| 			expectedVersion: "8.0", | ||||
| 			expectError:     true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "malformed version string without underscore", | ||||
| 			clientVersion: "SSH-2.0-beszel", | ||||
| 			expectError:   true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:          "empty version string", | ||||
| 			clientVersion: "", | ||||
| 			expectError:   true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:            "version string with underscore but no version", | ||||
| 			clientVersion:   "beszel_", | ||||
| 			expectedVersion: "", | ||||
| 			expectError:     true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:            "version with patch and build metadata", | ||||
| 			clientVersion:   "SSH-2.0-beszel_1.2.3+build.123", | ||||
| 			expectedVersion: "1.2.3+build.123", | ||||
| 			expectError:     false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result, err := extractHubVersion(tt.clientVersion) | ||||
|  | ||||
| 			if tt.expectError { | ||||
| 				assert.Error(t, err) | ||||
| 				return | ||||
| 			} | ||||
|  | ||||
| 			require.NoError(t, err) | ||||
| 			assert.Equal(t, tt.expectedVersion, result.String()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| ///////////////////////////////////////////////////////////////// | ||||
| /////////////// Hub Version Detection Tests //////////////////// | ||||
| ///////////////////////////////////////////////////////////////// | ||||
|  | ||||
| func TestGetHubVersion(t *testing.T) { | ||||
| 	agent, err := NewAgent("") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	// Mock SSH context that implements the ssh.Context interface | ||||
| 	mockCtx := &mockSSHContext{ | ||||
| 		sessionID:     "test-session-123", | ||||
| 		clientVersion: "SSH-2.0-beszel_0.12.0", | ||||
| 	} | ||||
|  | ||||
| 	// Test first call - should extract and cache version | ||||
| 	version := agent.getHubVersion("test-session-123", mockCtx) | ||||
| 	assert.Equal(t, "0.12.0", version.String()) | ||||
|  | ||||
| 	// Test second call - should return cached version | ||||
| 	mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached | ||||
| 	version = agent.getHubVersion("test-session-123", mockCtx) | ||||
| 	assert.Equal(t, "0.12.0", version.String()) // Should still be cached version | ||||
|  | ||||
| 	// Test different session - should extract new version | ||||
| 	version = agent.getHubVersion("different-session", mockCtx) | ||||
| 	assert.Equal(t, "0.11.0", version.String()) | ||||
|  | ||||
| 	// Test with invalid version string (non-beszel client) | ||||
| 	mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" | ||||
| 	version = agent.getHubVersion("invalid-session", mockCtx) | ||||
| 	assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients | ||||
|  | ||||
| 	// Test with no client version | ||||
| 	mockCtx.clientVersion = "" | ||||
| 	version = agent.getHubVersion("no-version-session", mockCtx) | ||||
| 	assert.True(t, version.EQ(semver.Version{})) // Should be empty version | ||||
| } | ||||
|  | ||||
| // mockSSHContext implements ssh.Context for testing | ||||
| type mockSSHContext struct { | ||||
| 	context.Context | ||||
| 	sync.Mutex | ||||
| 	sessionID     string | ||||
| 	clientVersion string | ||||
| } | ||||
|  | ||||
| func (m *mockSSHContext) SessionID() string { | ||||
| 	return m.sessionID | ||||
| } | ||||
|  | ||||
| func (m *mockSSHContext) ClientVersion() string { | ||||
| 	return m.clientVersion | ||||
| } | ||||
|  | ||||
| func (m *mockSSHContext) ServerVersion() string { | ||||
| 	return "SSH-2.0-beszel_test" | ||||
| } | ||||
|  | ||||
| func (m *mockSSHContext) Value(key interface{}) interface{} { | ||||
| 	if key == ssh.ContextKeyClientVersion { | ||||
| 		return m.clientVersion | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (m *mockSSHContext) User() string                    { return "test-user" } | ||||
| func (m *mockSSHContext) RemoteAddr() net.Addr            { return nil } | ||||
| func (m *mockSSHContext) LocalAddr() net.Addr             { return nil } | ||||
| func (m *mockSSHContext) Permissions() *ssh.Permissions   { return nil } | ||||
| func (m *mockSSHContext) SetValue(key, value interface{}) {} | ||||
|  | ||||
| ///////////////////////////////////////////////////////////////// | ||||
| /////////////// CBOR vs JSON Encoding Tests //////////////////// | ||||
| ///////////////////////////////////////////////////////////////// | ||||
|  | ||||
| // TestWriteToSessionEncoding tests that writeToSession actually encodes data in the correct format | ||||
| func TestWriteToSessionEncoding(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name             string | ||||
| 		hubVersion       string | ||||
| 		expectedUsesCbor bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:             "old hub version should use JSON", | ||||
| 			hubVersion:       "0.11.1", | ||||
| 			expectedUsesCbor: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:             "non-beta release should use CBOR", | ||||
| 			hubVersion:       "0.12.0", | ||||
| 			expectedUsesCbor: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:             "even newer hub version should use CBOR", | ||||
| 			hubVersion:       "0.16.4", | ||||
| 			expectedUsesCbor: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:             "beta version below release threshold should use JSON", | ||||
| 			hubVersion:       "0.12.0-beta0", | ||||
| 			expectedUsesCbor: false, | ||||
| 		}, | ||||
| 		// { | ||||
| 		// 	name:             "matching beta version should use CBOR", | ||||
| 		// 	hubVersion:       "0.12.0-beta2", | ||||
| 		// 	expectedUsesCbor: true, | ||||
| 		// }, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// Reset the global hubVersions map to ensure clean state for each test | ||||
| 			hubVersions = nil | ||||
|  | ||||
| 			agent, err := NewAgent("") | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			// Parse the test version | ||||
| 			version, err := semver.Parse(tt.hubVersion) | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			// Create test data to encode | ||||
| 			testData := createTestCombinedData() | ||||
|  | ||||
| 			var buf strings.Builder | ||||
| 			err = agent.writeToSession(&buf, testData, version) | ||||
| 			require.NoError(t, err) | ||||
|  | ||||
| 			encodedData := buf.String() | ||||
| 			require.NotEmpty(t, encodedData) | ||||
|  | ||||
| 			// Verify the encoding format by attempting to decode | ||||
| 			if tt.expectedUsesCbor { | ||||
| 				var decodedCbor system.CombinedData | ||||
| 				err = cbor.Unmarshal([]byte(encodedData), &decodedCbor) | ||||
| 				assert.NoError(t, err, "Should be valid CBOR data") | ||||
|  | ||||
| 				var decodedJson system.CombinedData | ||||
| 				err = json.Unmarshal([]byte(encodedData), &decodedJson) | ||||
| 				assert.Error(t, err, "Should not be valid JSON data") | ||||
|  | ||||
| 				assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname) | ||||
| 				assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu) | ||||
| 			} else { | ||||
| 				// Should be JSON - try to decode as JSON | ||||
| 				var decodedJson system.CombinedData | ||||
| 				err = json.Unmarshal([]byte(encodedData), &decodedJson) | ||||
| 				assert.NoError(t, err, "Should be valid JSON data") | ||||
|  | ||||
| 				var decodedCbor system.CombinedData | ||||
| 				err = cbor.Unmarshal([]byte(encodedData), &decodedCbor) | ||||
| 				assert.Error(t, err, "Should not be valid CBOR data") | ||||
|  | ||||
| 				// Verify the decoded JSON data matches our test data | ||||
| 				assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname) | ||||
| 				assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu) | ||||
|  | ||||
| 				// Verify it looks like JSON (starts with '{' and contains readable field names) | ||||
| 				assert.True(t, strings.HasPrefix(encodedData, "{"), "JSON should start with '{'") | ||||
| 				assert.Contains(t, encodedData, `"info"`, "JSON should contain readable field names") | ||||
| 				assert.Contains(t, encodedData, `"stats"`, "JSON should contain readable field names") | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Helper function to create test data for encoding tests | ||||
| func createTestCombinedData() *system.CombinedData { | ||||
| 	return &system.CombinedData{ | ||||
| 		Stats: system.Stats{ | ||||
| 			Cpu:       25.5, | ||||
| 			Mem:       8589934592, // 8GB | ||||
| 			MemUsed:   4294967296, // 4GB | ||||
| 			MemPct:    50.0, | ||||
| 			DiskTotal: 1099511627776, // 1TB | ||||
| 			DiskUsed:  549755813888,  // 512GB | ||||
| 			DiskPct:   50.0, | ||||
| 		}, | ||||
| 		Info: system.Info{ | ||||
| 			Hostname:     "test-host", | ||||
| 			Cores:        8, | ||||
| 			CpuModel:     "Test CPU Model", | ||||
| 			Uptime:       3600, | ||||
| 			AgentVersion: "0.12.0", | ||||
| 			Os:           system.Linux, | ||||
| 		}, | ||||
| 		Containers: []*container.Stats{ | ||||
| 			{ | ||||
| 				Name: "test-container", | ||||
| 				Cpu:  10.5, | ||||
| 				Mem:  1073741824, // 1GB | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestHubVersionCaching(t *testing.T) { | ||||
| 	// Reset the global hubVersions map to ensure clean state | ||||
| 	hubVersions = nil | ||||
|  | ||||
| 	agent, err := NewAgent("") | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	ctx1 := &mockSSHContext{ | ||||
| 		sessionID:     "session1", | ||||
| 		clientVersion: "SSH-2.0-beszel_0.12.0", | ||||
| 	} | ||||
| 	ctx2 := &mockSSHContext{ | ||||
| 		sessionID:     "session2", | ||||
| 		clientVersion: "SSH-2.0-beszel_0.11.0", | ||||
| 	} | ||||
|  | ||||
| 	// First calls should cache the versions | ||||
| 	v1 := agent.getHubVersion("session1", ctx1) | ||||
| 	v2 := agent.getHubVersion("session2", ctx2) | ||||
|  | ||||
| 	assert.Equal(t, "0.12.0", v1.String()) | ||||
| 	assert.Equal(t, "0.11.0", v2.String()) | ||||
|  | ||||
| 	// Verify caching by changing context but keeping same session ID | ||||
| 	ctx1.clientVersion = "SSH-2.0-beszel_0.10.0" | ||||
| 	v1Cached := agent.getHubVersion("session1", ctx1) | ||||
| 	assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version | ||||
|  | ||||
| 	// New session should get new version | ||||
| 	ctx3 := &mockSSHContext{ | ||||
| 		sessionID:     "session3", | ||||
| 		clientVersion: "SSH-2.0-beszel_0.13.0", | ||||
| 	} | ||||
| 	v3 := agent.getHubVersion("session3", ctx3) | ||||
| 	assert.Equal(t, "0.13.0", v3.String()) | ||||
| } | ||||
							
								
								
									
										305
									
								
								agent/system.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,305 @@ | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/henrygd/beszel" | ||||
| 	"github.com/henrygd/beszel/agent/battery" | ||||
| 	"github.com/henrygd/beszel/internal/entities/system" | ||||
|  | ||||
| 	"github.com/shirou/gopsutil/v4/cpu" | ||||
| 	"github.com/shirou/gopsutil/v4/disk" | ||||
| 	"github.com/shirou/gopsutil/v4/host" | ||||
| 	"github.com/shirou/gopsutil/v4/load" | ||||
| 	"github.com/shirou/gopsutil/v4/mem" | ||||
| 	psutilNet "github.com/shirou/gopsutil/v4/net" | ||||
| ) | ||||
|  | ||||
| // Sets initial / non-changing values about the host system | ||||
| func (a *Agent) initializeSystemInfo() { | ||||
| 	a.systemInfo.AgentVersion = beszel.Version | ||||
| 	a.systemInfo.Hostname, _ = os.Hostname() | ||||
|  | ||||
| 	platform, _, version, _ := host.PlatformInformation() | ||||
|  | ||||
| 	if platform == "darwin" { | ||||
| 		a.systemInfo.KernelVersion = version | ||||
| 		a.systemInfo.Os = system.Darwin | ||||
| 	} else if strings.Contains(platform, "indows") { | ||||
| 		a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version | ||||
| 		a.systemInfo.Os = system.Windows | ||||
| 	} else if platform == "freebsd" { | ||||
| 		a.systemInfo.Os = system.Freebsd | ||||
| 		a.systemInfo.KernelVersion = version | ||||
| 	} else { | ||||
| 		a.systemInfo.Os = system.Linux | ||||
| 	} | ||||
|  | ||||
| 	if a.systemInfo.KernelVersion == "" { | ||||
| 		a.systemInfo.KernelVersion, _ = host.KernelVersion() | ||||
| 	} | ||||
|  | ||||
| 	// cpu model | ||||
| 	if info, err := cpu.Info(); err == nil && len(info) > 0 { | ||||
| 		a.systemInfo.CpuModel = info[0].ModelName | ||||
| 	} | ||||
| 	// cores / threads | ||||
| 	a.systemInfo.Cores, _ = cpu.Counts(false) | ||||
| 	if threads, err := cpu.Counts(true); err == nil { | ||||
| 		if threads > 0 && threads < a.systemInfo.Cores { | ||||
| 			// in lxc logical cores reflects container limits, so use that as cores if lower | ||||
| 			a.systemInfo.Cores = threads | ||||
| 		} else { | ||||
| 			a.systemInfo.Threads = threads | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// zfs | ||||
| 	if _, err := getARCSize(); err != nil { | ||||
| 		slog.Debug("Not monitoring ZFS ARC", "err", err) | ||||
| 	} else { | ||||
| 		a.zfs = true | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Returns current info, stats about the host system | ||||
| func (a *Agent) getSystemStats() system.Stats { | ||||
| 	systemStats := system.Stats{} | ||||
|  | ||||
| 	// battery | ||||
| 	if battery.HasReadableBattery() { | ||||
| 		systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats() | ||||
| 	} | ||||
|  | ||||
| 	// cpu percent | ||||
| 	cpuPct, err := cpu.Percent(0, false) | ||||
| 	if err != nil { | ||||
| 		slog.Error("Error getting cpu percent", "err", err) | ||||
| 	} else if len(cpuPct) > 0 { | ||||
| 		systemStats.Cpu = twoDecimals(cpuPct[0]) | ||||
| 	} | ||||
|  | ||||
| 	// load average | ||||
| 	if avgstat, err := load.Avg(); err == nil { | ||||
| 		systemStats.LoadAvg[0] = avgstat.Load1 | ||||
| 		systemStats.LoadAvg[1] = avgstat.Load5 | ||||
| 		systemStats.LoadAvg[2] = avgstat.Load15 | ||||
| 		slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15) | ||||
| 	} else { | ||||
| 		slog.Error("Error getting load average", "err", err) | ||||
| 	} | ||||
|  | ||||
| 	// memory | ||||
| 	if v, err := mem.VirtualMemory(); err == nil { | ||||
| 		// swap | ||||
| 		systemStats.Swap = bytesToGigabytes(v.SwapTotal) | ||||
| 		systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) | ||||
| 		// cache + buffers value for default mem calculation | ||||
| 		cacheBuff := v.Total - v.Free - v.Used | ||||
| 		// htop memory calculation overrides | ||||
| 		if a.memCalc == "htop" { | ||||
| 			// note: gopsutil automatically adds SReclaimable to v.Cached | ||||
| 			cacheBuff = v.Cached + v.Buffers - v.Shared | ||||
| 			v.Used = v.Total - (v.Free + cacheBuff) | ||||
| 			v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 | ||||
| 		} | ||||
| 		// subtract ZFS ARC size from used memory and add as its own category | ||||
| 		if a.zfs { | ||||
| 			if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used { | ||||
| 				v.Used = v.Used - arcSize | ||||
| 				v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 | ||||
| 				systemStats.MemZfsArc = bytesToGigabytes(arcSize) | ||||
| 			} | ||||
| 		} | ||||
| 		systemStats.Mem = bytesToGigabytes(v.Total) | ||||
| 		systemStats.MemBuffCache = bytesToGigabytes(cacheBuff) | ||||
| 		systemStats.MemUsed = bytesToGigabytes(v.Used) | ||||
| 		systemStats.MemPct = twoDecimals(v.UsedPercent) | ||||
| 	} | ||||
|  | ||||
| 	// disk usage | ||||
| 	for _, stats := range a.fsStats { | ||||
| 		if d, err := disk.Usage(stats.Mountpoint); err == nil { | ||||
| 			stats.DiskTotal = bytesToGigabytes(d.Total) | ||||
| 			stats.DiskUsed = bytesToGigabytes(d.Used) | ||||
| 			if stats.Root { | ||||
| 				systemStats.DiskTotal = bytesToGigabytes(d.Total) | ||||
| 				systemStats.DiskUsed = bytesToGigabytes(d.Used) | ||||
| 				systemStats.DiskPct = twoDecimals(d.UsedPercent) | ||||
| 			} | ||||
| 		} else { | ||||
| 			// reset stats if error (likely unmounted) | ||||
| 			slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err) | ||||
| 			stats.DiskTotal = 0 | ||||
| 			stats.DiskUsed = 0 | ||||
| 			stats.TotalRead = 0 | ||||
| 			stats.TotalWrite = 0 | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// disk i/o | ||||
| 	if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { | ||||
| 		for _, d := range ioCounters { | ||||
| 			stats := a.fsStats[d.Name] | ||||
| 			if stats == nil { | ||||
| 				continue | ||||
| 			} | ||||
| 			secondsElapsed := time.Since(stats.Time).Seconds() | ||||
| 			readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed) | ||||
| 			writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed) | ||||
| 			// check for invalid values and reset stats if so | ||||
| 			if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 { | ||||
| 				slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond) | ||||
| 				a.initializeDiskIoStats(ioCounters) | ||||
| 				break | ||||
| 			} | ||||
| 			stats.Time = time.Now() | ||||
| 			stats.DiskReadPs = readPerSecond | ||||
| 			stats.DiskWritePs = writePerSecond | ||||
| 			stats.TotalRead = d.ReadBytes | ||||
| 			stats.TotalWrite = d.WriteBytes | ||||
| 			// if root filesystem, update system stats | ||||
| 			if stats.Root { | ||||
| 				systemStats.DiskReadPs = stats.DiskReadPs | ||||
| 				systemStats.DiskWritePs = stats.DiskWritePs | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// network stats | ||||
| 	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 { | ||||
| 		msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds()) | ||||
| 		a.netIoStats.Time = time.Now() | ||||
| 		totalBytesSent := uint64(0) | ||||
| 		totalBytesRecv := uint64(0) | ||||
| 		// sum all bytes sent and received | ||||
| 		for _, v := range netIO { | ||||
| 			// skip if not in valid network interfaces list | ||||
| 			if _, exists := a.netInterfaces[v.Name]; !exists { | ||||
| 				continue | ||||
| 			} | ||||
| 			totalBytesSent += v.BytesSent | ||||
| 			totalBytesRecv += v.BytesRecv | ||||
| 		} | ||||
| 		// add to systemStats | ||||
| 		var bytesSentPerSecond, bytesRecvPerSecond uint64 | ||||
| 		if msElapsed > 0 { | ||||
| 			bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed | ||||
| 			bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed | ||||
| 		} | ||||
| 		networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond)) | ||||
| 		networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond)) | ||||
| 		// add check for issue (#150) where sent is a massive number | ||||
| 		if networkSentPs > 10_000 || networkRecvPs > 10_000 { | ||||
| 			slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs) | ||||
| 			for _, v := range netIO { | ||||
| 				if _, exists := a.netInterfaces[v.Name]; !exists { | ||||
| 					continue | ||||
| 				} | ||||
| 				slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent) | ||||
| 			} | ||||
| 			// reset network I/O stats | ||||
| 			a.initializeNetIoStats() | ||||
| 		} else { | ||||
| 			systemStats.NetworkSent = networkSentPs | ||||
| 			systemStats.NetworkRecv = networkRecvPs | ||||
| 			systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond | ||||
| 			// update netIoStats | ||||
| 			a.netIoStats.BytesSent = totalBytesSent | ||||
| 			a.netIoStats.BytesRecv = totalBytesRecv | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// temperatures | ||||
| 	// TODO: maybe refactor to methods on systemStats | ||||
| 	a.updateTemperatures(&systemStats) | ||||
|  | ||||
| 	// GPU data | ||||
| 	if a.gpuManager != nil { | ||||
| 		// reset high gpu percent | ||||
| 		a.systemInfo.GpuPct = 0 | ||||
| 		// get current GPU data | ||||
| 		if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 { | ||||
| 			systemStats.GPUData = gpuData | ||||
|  | ||||
| 			// add temperatures | ||||
| 			if systemStats.Temperatures == nil { | ||||
| 				systemStats.Temperatures = make(map[string]float64, len(gpuData)) | ||||
| 			} | ||||
| 			highestTemp := 0.0 | ||||
| 			for _, gpu := range gpuData { | ||||
| 				if gpu.Temperature > 0 { | ||||
| 					systemStats.Temperatures[gpu.Name] = gpu.Temperature | ||||
| 					if a.sensorConfig.primarySensor == gpu.Name { | ||||
| 						a.systemInfo.DashboardTemp = gpu.Temperature | ||||
| 					} | ||||
| 					if gpu.Temperature > highestTemp { | ||||
| 						highestTemp = gpu.Temperature | ||||
| 					} | ||||
| 				} | ||||
| 				// update high gpu percent for dashboard | ||||
| 				a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage) | ||||
| 			} | ||||
| 			// use highest temp for dashboard temp if dashboard temp is unset | ||||
| 			if a.systemInfo.DashboardTemp == 0 { | ||||
| 				a.systemInfo.DashboardTemp = highestTemp | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// update base system info | ||||
| 	a.systemInfo.Cpu = systemStats.Cpu | ||||
| 	a.systemInfo.LoadAvg = systemStats.LoadAvg | ||||
| 	// TODO: remove these in future release in favor of load avg array | ||||
| 	a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0] | ||||
| 	a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1] | ||||
| 	a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2] | ||||
| 	a.systemInfo.MemPct = systemStats.MemPct | ||||
| 	a.systemInfo.DiskPct = systemStats.DiskPct | ||||
| 	a.systemInfo.Uptime, _ = host.Uptime() | ||||
| 	// TODO: in future release, remove MB bandwidth values in favor of bytes | ||||
| 	a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv) | ||||
| 	a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] | ||||
| 	slog.Debug("sysinfo", "data", a.systemInfo) | ||||
|  | ||||
| 	return systemStats | ||||
| } | ||||
|  | ||||
| // Returns the size of the ZFS ARC memory cache in bytes | ||||
| func getARCSize() (uint64, error) { | ||||
| 	file, err := os.Open("/proc/spl/kstat/zfs/arcstats") | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	defer file.Close() | ||||
|  | ||||
| 	// Scan the lines | ||||
| 	scanner := bufio.NewScanner(file) | ||||
| 	for scanner.Scan() { | ||||
| 		line := scanner.Text() | ||||
| 		if strings.HasPrefix(line, "size") { | ||||
| 			// Example line: size 4 15032385536 | ||||
| 			fields := strings.Fields(line) | ||||
| 			if len(fields) < 3 { | ||||
| 				return 0, err | ||||
| 			} | ||||
| 			// Return the size as uint64 | ||||
| 			return strconv.ParseUint(fields[2], 10, 64) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return 0, fmt.Errorf("failed to parse size field") | ||||
| } | ||||
							
								
								
									
										156
									
								
								agent/types.go
									
									
									
									
									
								
							
							
						
						| @@ -1,156 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import "time" | ||||
|  | ||||
| type SystemData struct { | ||||
| 	Stats      *SystemStats      `json:"stats"` | ||||
| 	Info       *SystemInfo       `json:"info"` | ||||
| 	Containers []*ContainerStats `json:"container"` | ||||
| } | ||||
|  | ||||
| type SystemInfo struct { | ||||
| 	Cores    int    `json:"c"` | ||||
| 	Threads  int    `json:"t"` | ||||
| 	CpuModel string `json:"m"` | ||||
| 	// Os       string  `json:"o"` | ||||
| 	Uptime  uint64  `json:"u"` | ||||
| 	Cpu     float64 `json:"cpu"` | ||||
| 	MemPct  float64 `json:"mp"` | ||||
| 	DiskPct float64 `json:"dp"` | ||||
| } | ||||
|  | ||||
| type SystemStats struct { | ||||
| 	Cpu          float64 `json:"cpu"` | ||||
| 	Mem          float64 `json:"m"` | ||||
| 	MemUsed      float64 `json:"mu"` | ||||
| 	MemPct       float64 `json:"mp"` | ||||
| 	MemBuffCache float64 `json:"mb"` | ||||
| 	Disk         float64 `json:"d"` | ||||
| 	DiskUsed     float64 `json:"du"` | ||||
| 	DiskPct      float64 `json:"dp"` | ||||
| 	DiskRead     float64 `json:"dr"` | ||||
| 	DiskWrite    float64 `json:"dw"` | ||||
| 	NetworkSent  float64 `json:"ns"` | ||||
| 	NetworkRecv  float64 `json:"nr"` | ||||
| } | ||||
|  | ||||
| type ContainerStats struct { | ||||
| 	Name string  `json:"n"` | ||||
| 	Cpu  float64 `json:"c"` | ||||
| 	Mem  float64 `json:"m"` | ||||
| 	// MemPct float64 `json:"mp"` | ||||
| } | ||||
|  | ||||
| type Container struct { | ||||
| 	ID      string `json:"Id"` | ||||
| 	IdShort string | ||||
| 	Names   []string | ||||
| 	Image   string | ||||
| 	ImageID string | ||||
| 	Command string | ||||
| 	Created int64 | ||||
| 	// Ports      []Port | ||||
| 	SizeRw     int64 `json:",omitempty"` | ||||
| 	SizeRootFs int64 `json:",omitempty"` | ||||
| 	Labels     map[string]string | ||||
| 	State      string | ||||
| 	Status     string | ||||
| 	HostConfig struct { | ||||
| 		NetworkMode string            `json:",omitempty"` | ||||
| 		Annotations map[string]string `json:",omitempty"` | ||||
| 	} | ||||
| 	// NetworkSettings *SummaryNetworkSettings | ||||
| 	// Mounts          []MountPoint | ||||
| } | ||||
|  | ||||
| type CStats struct { | ||||
| 	// Common stats | ||||
| 	Read    time.Time `json:"read"` | ||||
| 	PreRead time.Time `json:"preread"` | ||||
|  | ||||
| 	// Linux specific stats, not populated on Windows. | ||||
| 	// PidsStats  PidsStats  `json:"pids_stats,omitempty"` | ||||
| 	// BlkioStats BlkioStats `json:"blkio_stats,omitempty"` | ||||
|  | ||||
| 	// Windows specific stats, not populated on Linux. | ||||
| 	NumProcs uint32 `json:"num_procs"` | ||||
| 	// StorageStats StorageStats `json:"storage_stats,omitempty"` | ||||
|  | ||||
| 	// Shared stats | ||||
| 	CPUStats    CPUStats    `json:"cpu_stats,omitempty"` | ||||
| 	PreCPUStats CPUStats    `json:"precpu_stats,omitempty"` // "Pre"="Previous" | ||||
| 	MemoryStats MemoryStats `json:"memory_stats,omitempty"` | ||||
| } | ||||
|  | ||||
| type CPUStats struct { | ||||
| 	// CPU Usage. Linux and Windows. | ||||
| 	CPUUsage CPUUsage `json:"cpu_usage"` | ||||
|  | ||||
| 	// System Usage. Linux only. | ||||
| 	SystemUsage uint64 `json:"system_cpu_usage,omitempty"` | ||||
|  | ||||
| 	// Online CPUs. Linux only. | ||||
| 	OnlineCPUs uint32 `json:"online_cpus,omitempty"` | ||||
|  | ||||
| 	// Throttling Data. Linux only. | ||||
| 	// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"` | ||||
| } | ||||
|  | ||||
| type CPUUsage struct { | ||||
| 	// Total CPU time consumed. | ||||
| 	// Units: nanoseconds (Linux) | ||||
| 	// Units: 100's of nanoseconds (Windows) | ||||
| 	TotalUsage uint64 `json:"total_usage"` | ||||
|  | ||||
| 	// Total CPU time consumed per core (Linux). Not used on Windows. | ||||
| 	// Units: nanoseconds. | ||||
| 	PercpuUsage []uint64 `json:"percpu_usage,omitempty"` | ||||
|  | ||||
| 	// Time spent by tasks of the cgroup in kernel mode (Linux). | ||||
| 	// Time spent by all container processes in kernel mode (Windows). | ||||
| 	// Units: nanoseconds (Linux). | ||||
| 	// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers. | ||||
| 	UsageInKernelmode uint64 `json:"usage_in_kernelmode"` | ||||
|  | ||||
| 	// Time spent by tasks of the cgroup in user mode (Linux). | ||||
| 	// Time spent by all container processes in user mode (Windows). | ||||
| 	// Units: nanoseconds (Linux). | ||||
| 	// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers | ||||
| 	UsageInUsermode uint64 `json:"usage_in_usermode"` | ||||
| } | ||||
|  | ||||
| type MemoryStats struct { | ||||
|  | ||||
| 	// current res_counter usage for memory | ||||
| 	Usage uint64 `json:"usage,omitempty"` | ||||
| 	Cache uint64 `json:"cache,omitempty"` | ||||
| 	// maximum usage ever recorded. | ||||
| 	MaxUsage uint64 `json:"max_usage,omitempty"` | ||||
| 	// TODO(vishh): Export these as stronger types. | ||||
| 	// all the stats exported via memory.stat. | ||||
| 	Stats map[string]uint64 `json:"stats,omitempty"` | ||||
| 	// number of times memory usage hits limits. | ||||
| 	Failcnt uint64 `json:"failcnt,omitempty"` | ||||
| 	Limit   uint64 `json:"limit,omitempty"` | ||||
|  | ||||
| 	// committed bytes | ||||
| 	Commit uint64 `json:"commitbytes,omitempty"` | ||||
| 	// peak committed bytes | ||||
| 	CommitPeak uint64 `json:"commitpeakbytes,omitempty"` | ||||
| 	// private working set | ||||
| 	PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"` | ||||
| } | ||||
|  | ||||
| type DiskIoStats struct { | ||||
| 	Read       uint64 | ||||
| 	Write      uint64 | ||||
| 	Time       time.Time | ||||
| 	Filesystem string | ||||
| } | ||||
|  | ||||
| type NetIoStats struct { | ||||
| 	BytesRecv uint64 | ||||
| 	BytesSent uint64 | ||||
| 	Time      time.Time | ||||
| 	Name      string | ||||
| } | ||||
							
								
								
									
										201
									
								
								agent/update.go
									
									
									
									
									
								
							
							
						
						| @@ -1,54 +1,165 @@ | ||||
| package main | ||||
| package agent | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"runtime" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/blang/semver" | ||||
| 	"github.com/rhysd/go-github-selfupdate/selfupdate" | ||||
| 	"github.com/henrygd/beszel/internal/ghupdate" | ||||
| ) | ||||
|  | ||||
| func updateBeszel() { | ||||
| 	var latest *selfupdate.Release | ||||
| 	var found bool | ||||
| 	var err error | ||||
| 	currentVersion := semver.MustParse(Version) | ||||
| 	fmt.Println("beszel-agent", currentVersion) | ||||
| 	fmt.Println("Checking for updates...") | ||||
| 	updater, _ := selfupdate.NewUpdater(selfupdate.Config{ | ||||
| 		Filters: []string{"beszel-agent"}, | ||||
| 	}) | ||||
| 	latest, found, err = updater.DetectLatest("henrygd/beszel") | ||||
|  | ||||
| 	if err != nil { | ||||
| 		fmt.Println("Error checking for updates:", err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
|  | ||||
| 	if !found { | ||||
| 		fmt.Println("No updates found") | ||||
| 		os.Exit(0) | ||||
| 	} | ||||
|  | ||||
| 	fmt.Println("Latest version:", latest.Version) | ||||
|  | ||||
| 	if latest.Version.LTE(currentVersion) { | ||||
| 		fmt.Println("You are up to date") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var binaryPath string | ||||
| 	fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version) | ||||
| 	binaryPath, err = os.Executable() | ||||
| 	if err != nil { | ||||
| 		fmt.Println("Error getting binary path:", err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 	err = selfupdate.UpdateTo(latest.AssetURL, binaryPath) | ||||
| 	if err != nil { | ||||
| 		fmt.Println("Please try rerunning with sudo. Error:", err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 	fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes)) | ||||
| // restarter knows how to restart the beszel-agent service. | ||||
| type restarter interface { | ||||
| 	Restart() error | ||||
| } | ||||
|  | ||||
| type systemdRestarter struct{ cmd string } | ||||
|  | ||||
| func (s *systemdRestarter) Restart() error { | ||||
| 	// Only restart if the service is active | ||||
| 	if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…") | ||||
| 	return exec.Command(s.cmd, "restart", "beszel-agent.service").Run() | ||||
| } | ||||
|  | ||||
| type openRCRestarter struct{ cmd string } | ||||
|  | ||||
| func (o *openRCRestarter) Restart() error { | ||||
| 	if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…") | ||||
| 	return exec.Command(o.cmd, "restart", "beszel-agent").Run() | ||||
| } | ||||
|  | ||||
| type openWRTRestarter struct{ cmd string } | ||||
|  | ||||
| func (w *openWRTRestarter) Restart() error { | ||||
| 	if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…") | ||||
| 	return exec.Command(w.cmd, "restart", "beszel-agent").Run() | ||||
| } | ||||
|  | ||||
| type freeBSDRestarter struct{ cmd string } | ||||
|  | ||||
| func (f *freeBSDRestarter) Restart() error { | ||||
| 	if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…") | ||||
| 	return exec.Command(f.cmd, "beszel-agent", "restart").Run() | ||||
| } | ||||
|  | ||||
| func detectRestarter() restarter { | ||||
| 	if path, err := exec.LookPath("systemctl"); err == nil { | ||||
| 		return &systemdRestarter{cmd: path} | ||||
| 	} | ||||
| 	if path, err := exec.LookPath("rc-service"); err == nil { | ||||
| 		return &openRCRestarter{cmd: path} | ||||
| 	} | ||||
| 	if path, err := exec.LookPath("service"); err == nil { | ||||
| 		if runtime.GOOS == "freebsd" { | ||||
| 			return &freeBSDRestarter{cmd: path} | ||||
| 		} | ||||
| 		return &openWRTRestarter{cmd: path} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Update checks GitHub for a newer release of beszel-agent, applies it, | ||||
| // fixes SELinux context if needed, and restarts the service. | ||||
| func Update(useMirror bool) error { | ||||
| 	exePath, _ := os.Executable() | ||||
|  | ||||
| 	dataDir, err := getDataDir() | ||||
| 	if err != nil { | ||||
| 		dataDir = os.TempDir() | ||||
| 	} | ||||
| 	updated, err := ghupdate.Update(ghupdate.Config{ | ||||
| 		ArchiveExecutable: "beszel-agent", | ||||
| 		DataDir:           dataDir, | ||||
| 		UseMirror:         useMirror, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| 	if !updated { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// make sure the file is executable | ||||
| 	if err := os.Chmod(exePath, 0755); err != nil { | ||||
| 		ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err) | ||||
| 	} | ||||
| 	// set ownership to beszel:beszel if possible | ||||
| 	if chownPath, err := exec.LookPath("chown"); err == nil { | ||||
| 		if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil { | ||||
| 			ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 6) Fix SELinux context if necessary | ||||
| 	if err := handleSELinuxContext(exePath); err != nil { | ||||
| 		ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	// 7) Restart service if running under a recognised init system | ||||
| 	if r := detectRestarter(); r != nil { | ||||
| 		if err := r.Restart(); err != nil { | ||||
| 			ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err) | ||||
| 			ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.") | ||||
| 		} else { | ||||
| 			ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully") | ||||
| 		} | ||||
| 	} else { | ||||
| 		ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // handleSELinuxContext restores or applies the correct SELinux label to the binary. | ||||
| func handleSELinuxContext(path string) error { | ||||
| 	out, err := exec.Command("getenforce").Output() | ||||
| 	if err != nil { | ||||
| 		// SELinux not enabled or getenforce not available | ||||
| 		return nil | ||||
| 	} | ||||
| 	state := strings.TrimSpace(string(out)) | ||||
| 	if state == "Disabled" { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…") | ||||
| 	var errs []string | ||||
|  | ||||
| 	// Try persistent context via semanage+restorecon | ||||
| 	if semanagePath, err := exec.LookPath("semanage"); err == nil { | ||||
| 		if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil { | ||||
| 			errs = append(errs, "semanage fcontext failed: "+err.Error()) | ||||
| 		} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil { | ||||
| 			if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil { | ||||
| 				errs = append(errs, "restorecon failed: "+err.Error()) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Fallback to temporary context via chcon | ||||
| 	if chconPath, err := exec.LookPath("chcon"); err == nil { | ||||
| 		if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil { | ||||
| 			errs = append(errs, "chcon failed: "+err.Error()) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(errs) > 0 { | ||||
| 		return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; ")) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										15
									
								
								agent/utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| package agent | ||||
|  | ||||
| import "math" | ||||
|  | ||||
| func bytesToMegabytes(b float64) float64 { | ||||
| 	return twoDecimals(b / 1048576) | ||||
| } | ||||
|  | ||||
| func bytesToGigabytes(b uint64) float64 { | ||||
| 	return twoDecimals(float64(b) / 1073741824) | ||||
| } | ||||
|  | ||||
| func twoDecimals(value float64) float64 { | ||||
| 	return math.Round(value*100) / 100 | ||||
| } | ||||
							
								
								
									
										15
									
								
								beszel.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| // Package beszel provides core application constants and version information | ||||
| // which are used throughout the application. | ||||
| package beszel | ||||
|  | ||||
| import "github.com/blang/semver" | ||||
|  | ||||
| const ( | ||||
| 	// Version is the current version of the application. | ||||
| 	Version = "0.12.7" | ||||
| 	// AppName is the name of the application. | ||||
| 	AppName = "beszel" | ||||
| ) | ||||
|  | ||||
| // MinVersionCbor is the minimum supported version for CBOR compatibility. | ||||
| var MinVersionCbor = semver.MustParse("0.12.0") | ||||
							
								
								
									
										69
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,69 @@ | ||||
| module github.com/henrygd/beszel | ||||
|  | ||||
| go 1.25.1 | ||||
|  | ||||
| // lock shoutrrr to specific version to allow review before updating | ||||
| replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8 | ||||
|  | ||||
| require ( | ||||
| 	github.com/blang/semver v3.5.1+incompatible | ||||
| 	github.com/distatus/battery v0.11.0 | ||||
| 	github.com/fxamacker/cbor/v2 v2.9.0 | ||||
| 	github.com/gliderlabs/ssh v0.3.8 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/lxzan/gws v1.8.9 | ||||
| 	github.com/nicholas-fedor/shoutrrr v0.8.17 | ||||
| 	github.com/pocketbase/dbx v1.11.0 | ||||
| 	github.com/pocketbase/pocketbase v0.29.3 | ||||
| 	github.com/shirou/gopsutil/v4 v4.25.6 | ||||
| 	github.com/spf13/cast v1.9.2 | ||||
| 	github.com/spf13/cobra v1.9.1 | ||||
| 	github.com/spf13/pflag v1.0.7 | ||||
| 	github.com/stretchr/testify v1.11.0 | ||||
| 	golang.org/x/crypto v0.41.0 | ||||
| 	golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b | ||||
| 	gopkg.in/yaml.v3 v3.0.1 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect | ||||
| 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | ||||
| 	github.com/disintegration/imaging v1.6.2 // indirect | ||||
| 	github.com/dolthub/maphash v0.1.0 // indirect | ||||
| 	github.com/domodwyer/mailyak/v3 v3.6.2 // indirect | ||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | ||||
| 	github.com/ebitengine/purego v0.8.4 // indirect | ||||
| 	github.com/fatih/color v1.18.0 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.9 // indirect | ||||
| 	github.com/ganigeorgiev/fexpr v0.5.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-sql-driver/mysql v1.9.1 // indirect | ||||
| 	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect | ||||
| 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||||
| 	github.com/klauspost/compress v1.18.0 // indirect | ||||
| 	github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.14 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||||
| 	github.com/tklauser/go-sysconf v0.3.15 // indirect | ||||
| 	github.com/tklauser/numcpus v0.10.0 // indirect | ||||
| 	github.com/x448/float16 v0.8.4 // indirect | ||||
| 	github.com/yusufpapurcu/wmi v1.2.4 // indirect | ||||
| 	golang.org/x/image v0.30.0 // indirect | ||||
| 	golang.org/x/net v0.43.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.30.0 // indirect | ||||
| 	golang.org/x/sync v0.16.0 // indirect | ||||
| 	golang.org/x/sys v0.35.0 // indirect | ||||
| 	golang.org/x/text v0.28.0 // indirect | ||||
| 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | ||||
| 	howett.net/plist v1.0.1 // indirect | ||||
| 	modernc.org/libc v1.66.3 // indirect | ||||
| 	modernc.org/mathutil v1.7.1 // indirect | ||||
| 	modernc.org/memory v1.11.0 // indirect | ||||
| 	modernc.org/sqlite v1.38.2 // indirect | ||||
| ) | ||||
							
								
								
									
										193
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,193 @@ | ||||
| filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= | ||||
| filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= | ||||
| 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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= | ||||
| github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= | ||||
| github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= | ||||
| github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= | ||||
| github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= | ||||
| github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= | ||||
| github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc= | ||||
| github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k= | ||||
| github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= | ||||
| github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= | ||||
| github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= | ||||
| github.com/domodwyer/mailyak/v3 v3.6.2/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||||
| github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= | ||||
| github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | ||||
| github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= | ||||
| github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= | ||||
| github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | ||||
| github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | ||||
| github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= | ||||
| github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= | ||||
| github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= | ||||
| github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= | ||||
| github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= | ||||
| github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= | ||||
| github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= | ||||
| 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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||
| github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= | ||||
| github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | ||||
| github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | ||||
| github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= | ||||
| github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= | ||||
| github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= | ||||
| github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= | ||||
| github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= | ||||
| github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= | ||||
| github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= | ||||
| github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= | ||||
| github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= | ||||
| github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||
| github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||||
| github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||||
| github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= | ||||
| github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= | ||||
| 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||||
| github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||||
| github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= | ||||
| github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= | ||||
| github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= | ||||
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= | ||||
| github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||||
| github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d h1:vFzYZc8yji+9DmNRhpEbs8VBK4CgV/DPfGzeVJSSp/8= | ||||
| github.com/lufia/plan9stats v0.0.0-20250821153705-5981dea3221d/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= | ||||
| github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= | ||||
| github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= | ||||
| github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | ||||
| github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| 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/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8= | ||||
| github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c= | ||||
| github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= | ||||
| github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= | ||||
| github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= | ||||
| github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= | ||||
| 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= | ||||
| github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= | ||||
| github.com/pocketbase/pocketbase v0.29.3 h1:Mj8o5awsbVJIdIoTuQNhfC2oL/c4aImQ3RyfFZlzFVg= | ||||
| github.com/pocketbase/pocketbase v0.29.3/go.mod h1:oGpT67LObxCFK4V2fSL7J9YnPbBnnshOpJ5v3zcneww= | ||||
| 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | ||||
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | ||||
| 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||
| github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= | ||||
| github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= | ||||
| github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= | ||||
| github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= | ||||
| github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= | ||||
| github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= | ||||
| github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||
| github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= | ||||
| github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= | ||||
| github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= | ||||
| github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||||
| github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= | ||||
| github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= | ||||
| github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= | ||||
| github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= | ||||
| github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= | ||||
| github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= | ||||
| github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= | ||||
| github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= | ||||
| go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= | ||||
| go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= | ||||
| golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= | ||||
| golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= | ||||
| golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= | ||||
| golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= | ||||
| golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= | ||||
| golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= | ||||
| golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= | ||||
| golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= | ||||
| golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= | ||||
| golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= | ||||
| golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= | ||||
| golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= | ||||
| golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||
| golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= | ||||
| golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= | ||||
| golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||||
| golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= | ||||
| golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= | ||||
| 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= | ||||
| golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= | ||||
| golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= | ||||
| google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | ||||
| google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= | ||||
| google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= | ||||
| 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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= | ||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= | ||||
| howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= | ||||
| modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= | ||||
| modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= | ||||
| modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= | ||||
| modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= | ||||
| modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= | ||||
| modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= | ||||
| modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||
| modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | ||||
| modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | ||||
| modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= | ||||
| modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= | ||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||
| modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | ||||
| modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= | ||||
| modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | ||||
| modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | ||||
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||||
| modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= | ||||
| modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= | ||||
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||||
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||
| modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | ||||
| @@ -1,36 +0,0 @@ | ||||
| # version: 1 | ||||
|  | ||||
| project_name: beszel | ||||
|  | ||||
| before: | ||||
|   hooks: | ||||
|     - go mod tidy | ||||
|  | ||||
| builds: | ||||
|   - env: | ||||
|       - CGO_ENABLED=0 | ||||
|     goos: | ||||
|       - linux | ||||
|       - darwin | ||||
|     goarch: | ||||
|       - amd64 | ||||
|       - arm64 | ||||
|  | ||||
| archives: | ||||
|   - format: tar.gz | ||||
|     name_template: >- | ||||
|       {{ .ProjectName }}_ | ||||
|       {{- .Os }}_ | ||||
|       {{- .Arch }} | ||||
|     # use zip for windows archives | ||||
|     # format_overrides: | ||||
|     #   - goos: windows | ||||
|     #     format: zip | ||||
|  | ||||
| changelog: | ||||
|   disable: true | ||||
|   sort: asc | ||||
|   filters: | ||||
|     exclude: | ||||
|       - '^docs:' | ||||
|       - '^test:' | ||||
							
								
								
									
										142
									
								
								hub/alerts.go
									
									
									
									
									
								
							
							
						
						| @@ -1,142 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net/mail" | ||||
|  | ||||
| 	"github.com/pocketbase/dbx" | ||||
| 	"github.com/pocketbase/pocketbase/models" | ||||
| 	"github.com/pocketbase/pocketbase/tools/mailer" | ||||
| 	"github.com/pocketbase/pocketbase/tools/types" | ||||
| ) | ||||
|  | ||||
| func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) { | ||||
| 	alertRecords, err := app.Dao().FindRecordsByExpr("alerts", | ||||
| 		dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.Get("id")}), | ||||
| 	) | ||||
| 	if err != nil || len(alertRecords) == 0 { | ||||
| 		// log.Println("no alerts found for system") | ||||
| 		return | ||||
| 	} | ||||
| 	// log.Println("found alerts", len(alertRecords)) | ||||
| 	var systemInfo *SystemInfo | ||||
| 	for _, alertRecord := range alertRecords { | ||||
| 		name := alertRecord.Get("name").(string) | ||||
| 		switch name { | ||||
| 		case "Status": | ||||
| 			handleStatusAlerts(newStatus, oldRecord, alertRecord) | ||||
| 		case "CPU", "Memory", "Disk": | ||||
| 			if newStatus != "up" { | ||||
| 				continue | ||||
| 			} | ||||
| 			if systemInfo == nil { | ||||
| 				systemInfo = getSystemInfo(newRecord) | ||||
| 			} | ||||
| 			if name == "CPU" { | ||||
| 				handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu) | ||||
| 			} else if name == "Memory" { | ||||
| 				handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct) | ||||
| 			} else if name == "Disk" { | ||||
| 				handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func getSystemInfo(record *models.Record) *SystemInfo { | ||||
| 	var SystemInfo SystemInfo | ||||
| 	json.Unmarshal([]byte(record.Get("info").(types.JsonRaw)), &SystemInfo) | ||||
| 	return &SystemInfo | ||||
| } | ||||
|  | ||||
| func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) { | ||||
| 	triggered := alertRecord.Get("triggered").(bool) | ||||
| 	threshold := alertRecord.Get("value").(float64) | ||||
| 	// fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered) | ||||
| 	var subject string | ||||
| 	var body string | ||||
| 	if !triggered && curValue > threshold { | ||||
| 		alertRecord.Set("triggered", true) | ||||
| 		systemName := newRecord.Get("name").(string) | ||||
| 		subject = fmt.Sprintf("%s usage threshold exceeded on %s", name, systemName) | ||||
| 		body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n- Beszel", name, systemName, curValue) | ||||
| 	} else if triggered && curValue <= threshold { | ||||
| 		alertRecord.Set("triggered", false) | ||||
| 		systemName := newRecord.Get("name").(string) | ||||
| 		subject = fmt.Sprintf("%s usage returned below threshold on %s", name, systemName) | ||||
| 		body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName) | ||||
| 	} else { | ||||
| 		// fmt.Println(name, "not triggered") | ||||
| 		return | ||||
| 	} | ||||
| 	if err := app.Dao().SaveRecord(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 := app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { | ||||
| 		// app.Logger().Error("failed to expand user relation", "errs", errs) | ||||
| 		return | ||||
| 	} | ||||
| 	if user := alertRecord.ExpandedOne("user"); user != nil { | ||||
| 		sendAlert(EmailData{ | ||||
| 			to:   user.Get("email").(string), | ||||
| 			subj: subject, | ||||
| 			body: body, | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error { | ||||
| 	var alertStatus string | ||||
| 	switch newStatus { | ||||
| 	case "up": | ||||
| 		if oldRecord.Get("status") == "down" { | ||||
| 			alertStatus = "up" | ||||
| 		} | ||||
| 	case "down": | ||||
| 		if oldRecord.Get("status") == "up" { | ||||
| 			alertStatus = "down" | ||||
| 		} | ||||
| 	} | ||||
| 	if alertStatus == "" { | ||||
| 		return nil | ||||
| 	} | ||||
| 	// expand the user relation | ||||
| 	if errs := 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 := oldRecord.Get("name").(string) | ||||
| 	sendAlert(EmailData{ | ||||
| 		to:   user.Get("email").(string), | ||||
| 		subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), | ||||
| 		body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus), | ||||
| 	}) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func sendAlert(data EmailData) { | ||||
| 	// fmt.Println("sending alert", "to", data.to, "subj", data.subj, "body", data.body) | ||||
| 	message := &mailer.Message{ | ||||
| 		From: mail.Address{ | ||||
| 			Address: app.Settings().Meta.SenderAddress, | ||||
| 			Name:    app.Settings().Meta.SenderName, | ||||
| 		}, | ||||
| 		To:      []mail.Address{{Address: data.to}}, | ||||
| 		Subject: data.subj, | ||||
| 		Text:    data.body, | ||||
| 	} | ||||
| 	if err := app.NewMailClient().Send(message); err != nil { | ||||
| 		app.Logger().Error("Failed to send alert: ", "err", err.Error()) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										90
									
								
								hub/go.mod
									
									
									
									
									
								
							
							
						
						| @@ -1,90 +0,0 @@ | ||||
| module beszel | ||||
|  | ||||
| go 1.22.4 | ||||
|  | ||||
| require ( | ||||
| 	github.com/blang/semver v3.5.1+incompatible | ||||
| 	github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 | ||||
| 	github.com/pocketbase/dbx v1.10.1 | ||||
| 	github.com/pocketbase/pocketbase v0.22.16 | ||||
| 	github.com/rhysd/go-github-selfupdate v1.2.3 | ||||
| 	github.com/spf13/cobra v1.8.1 | ||||
| 	golang.org/x/crypto v0.24.0 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/AlecAivazis/survey/v2 v2.3.7 // indirect | ||||
| 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2 v1.30.1 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/config v1.27.23 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/credentials v1.17.23 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1 // indirect | ||||
| 	github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 // indirect | ||||
| 	github.com/aws/smithy-go v1.20.3 // indirect | ||||
| 	github.com/disintegration/imaging v1.6.2 // indirect | ||||
| 	github.com/domodwyer/mailyak/v3 v3.6.2 // indirect | ||||
| 	github.com/dustin/go-humanize v1.0.1 // indirect | ||||
| 	github.com/fatih/color v1.17.0 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.4 // indirect | ||||
| 	github.com/ganigeorgiev/fexpr v0.4.1 // indirect | ||||
| 	github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect | ||||
| 	github.com/goccy/go-json v0.10.3 // indirect | ||||
| 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect | ||||
| 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect | ||||
| 	github.com/google/go-github/v30 v30.1.0 // indirect | ||||
| 	github.com/google/go-querystring v1.0.0 // indirect | ||||
| 	github.com/google/uuid v1.6.0 // indirect | ||||
| 	github.com/googleapis/gax-go/v2 v2.12.5 // 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/mousetrap v1.1.0 // indirect | ||||
| 	github.com/jmespath/go-jmespath v0.4.0 // indirect | ||||
| 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.13 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | ||||
| 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | ||||
| 	github.com/ncruces/go-strftime v0.1.9 // indirect | ||||
| 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||||
| 	github.com/spf13/cast v1.6.0 // indirect | ||||
| 	github.com/spf13/pflag v1.0.5 // indirect | ||||
| 	github.com/tcnksm/go-gitconfig v0.1.2 // indirect | ||||
| 	github.com/ulikunitz/xz v0.5.9 // indirect | ||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||
| 	github.com/valyala/fasttemplate v1.2.2 // indirect | ||||
| 	go.opencensus.io v0.24.0 // indirect | ||||
| 	gocloud.dev v0.37.0 // indirect | ||||
| 	golang.org/x/image v0.18.0 // indirect | ||||
| 	golang.org/x/net v0.26.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.21.0 // indirect | ||||
| 	golang.org/x/sync v0.7.0 // indirect | ||||
| 	golang.org/x/sys v0.21.0 // indirect | ||||
| 	golang.org/x/term v0.21.0 // indirect | ||||
| 	golang.org/x/text v0.16.0 // indirect | ||||
| 	golang.org/x/time v0.5.0 // indirect | ||||
| 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect | ||||
| 	google.golang.org/api v0.187.0 // indirect | ||||
| 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect | ||||
| 	google.golang.org/grpc v1.65.0 // indirect | ||||
| 	google.golang.org/protobuf v1.34.2 // indirect | ||||
| 	modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect | ||||
| 	modernc.org/libc v1.52.1 // indirect | ||||
| 	modernc.org/mathutil v1.6.0 // indirect | ||||
| 	modernc.org/memory v1.8.0 // indirect | ||||
| 	modernc.org/sqlite v1.30.1 // indirect | ||||
| 	modernc.org/strutil v1.2.0 // indirect | ||||
| 	modernc.org/token v1.1.0 // indirect | ||||
| ) | ||||
							
								
								
									
										399
									
								
								hub/go.sum
									
									
									
									
									
								
							
							
						
						| @@ -1,399 +0,0 @@ | ||||
| cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= | ||||
| cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= | ||||
| cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= | ||||
| cloud.google.com/go/auth v0.6.1 h1:T0Zw1XM5c1GlpN2HYr2s+m3vr1p2wy+8VN+Z1FKxW38= | ||||
| cloud.google.com/go/auth v0.6.1/go.mod h1:eFHG7zDzbXHKmjJddFG/rBlcGp6t25SwRUiEQSlO4x4= | ||||
| cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= | ||||
| cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= | ||||
| cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU= | ||||
| cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= | ||||
| cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= | ||||
| cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= | ||||
| cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= | ||||
| cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY= | ||||
| cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o= | ||||
| filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= | ||||
| 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/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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= | ||||
| github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= | ||||
| github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= | ||||
| github.com/aws/aws-sdk-go v1.51.11 h1:El5VypsMIz7sFwAAj/j06JX9UGs4KAbAIEaZ57bNY4s= | ||||
| github.com/aws/aws-sdk-go v1.51.11/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= | ||||
| github.com/aws/aws-sdk-go-v2 v1.30.1 h1:4y/5Dvfrhd1MxRDD77SrfsDaj8kUkkljU7XE83NPV+o= | ||||
| github.com/aws/aws-sdk-go-v2 v1.30.1/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= | ||||
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= | ||||
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= | ||||
| github.com/aws/aws-sdk-go-v2/config v1.27.23 h1:Cr/gJEa9NAS7CDAjbnB7tHYb3aLZI2gVggfmSAasDac= | ||||
| github.com/aws/aws-sdk-go-v2/config v1.27.23/go.mod h1:WMMYHqLCFu5LH05mFOF5tsq1PGEMfKbu083VKqLCd0o= | ||||
| github.com/aws/aws-sdk-go-v2/credentials v1.17.23 h1:G1CfmLVoO2TdQ8z9dW+JBc/r8+MqyPQhXCafNZcXVZo= | ||||
| github.com/aws/aws-sdk-go-v2/credentials v1.17.23/go.mod h1:V/DvSURn6kKgcuKEk4qwSwb/fZ2d++FFARtWSbXnLqY= | ||||
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9 h1:Aznqksmd6Rfv2HQN9cpqIV/lQRMaIpJkLLaJ1ZI76no= | ||||
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.9/go.mod h1:WQr3MY7AxGNxaqAtsDWn+fBxmd4XvLkzeqQ8P1VM0/w= | ||||
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 h1:6eKRM6fgeXG4krRO9XKz755vuRhT5UyB9M1W6vjA3JU= | ||||
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4/go.mod h1:h0TjcRi+nTob6fksqubKOe+Hra8uqfgmN+vuw4xRwWE= | ||||
| github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13 h1:5SAoZ4jYpGH4721ZNoS1znQrhOfZinOhc4XuTXx/nVc= | ||||
| github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.13/go.mod h1:+rdA6ZLpaSeM7tSg/B0IEDinCIBJGmW8rKDFkYpP04g= | ||||
| github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13 h1:WIijqeaAO7TYFLbhsZmi2rgLEAtWOC1LhxCAVTJlSKw= | ||||
| github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.13/go.mod h1:i+kbfa76PQbWw/ULoWnp51EYVWH4ENln76fLQE3lXT8= | ||||
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= | ||||
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= | ||||
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13 h1:THZJJ6TU/FOiM7DZFnisYV9d49oxXWUzsVIMTuf3VNU= | ||||
| github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.13/go.mod h1:VISUTg6n+uBaYIWPBaIG0jk7mbBxm7DUqBtU2cUDDWI= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15 h1:2jyRZ9rVIMisyQRnhSS/SqlckveoxXneIumECVFP91Y= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.15/go.mod h1:bDRG3m382v1KJBk1cKz7wIajg87/61EiiymEyfLvAe0= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15 h1:I9zMeF107l0rJrpnHpjEiiTSCKYAIw8mALiXcPsGBiA= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.15/go.mod h1:9xWJ3Q/S6Ojusz1UIkfycgD1mGirJfLLKqq3LPT7WN8= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13 h1:Eq2THzHt6P41mpjS2sUzz/3dJYFRqdWZ+vQaEMm98EM= | ||||
| github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.13/go.mod h1:FgwTca6puegxgCInYwGjmd4tB9195Dd6LCuA+8MjpWw= | ||||
| github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0 h1:4rhV0Hn+bf8IAIUphRX1moBcEvKJipCPmswMCl6Q5mw= | ||||
| github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0/go.mod h1:hdV0NTYd0RwV4FvNKhKUNbPLZoq9CTr/lke+3I7aCAI= | ||||
| github.com/aws/aws-sdk-go-v2/service/sso v1.22.1 h1:p1GahKIjyMDZtiKoIn0/jAj/TkMzfzndDv5+zi2Mhgc= | ||||
| github.com/aws/aws-sdk-go-v2/service/sso v1.22.1/go.mod h1:/vWdhoIoYA5hYoPZ6fm7Sv4d8701PiG5VKe8/pPJL60= | ||||
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1 h1:lCEv9f8f+zJ8kcFeAjRZsekLd/x5SAm96Cva+VbUdo8= | ||||
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.1/go.mod h1:xyFHA4zGxgYkdD73VeezHt3vSKEG9EmFnGwoKlP00u4= | ||||
| github.com/aws/aws-sdk-go-v2/service/sts v1.30.1 h1:+woJ607dllHJQtsnJLi52ycuqHMwlW+Wqm2Ppsfp4nQ= | ||||
| github.com/aws/aws-sdk-go-v2/service/sts v1.30.1/go.mod h1:jiNR3JqT15Dm+QWq2SRgh0x0bCNSRP2L25+CqPNpJlQ= | ||||
| github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= | ||||
| github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= | ||||
| 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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | ||||
| github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||
| github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= | ||||
| 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= | ||||
| github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= | ||||
| github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= | ||||
| github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= | ||||
| github.com/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/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.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= | ||||
| 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.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= | ||||
| github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= | ||||
| 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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | ||||
| github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= | ||||
| github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= | ||||
| github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= | ||||
| github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k= | ||||
| github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= | ||||
| github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= | ||||
| github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||
| github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | ||||
| github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||||
| github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= | ||||
| github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= | ||||
| github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= | ||||
| github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4= | ||||
| github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= | ||||
| github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= | ||||
| github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | ||||
| github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= | ||||
| github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= | ||||
| 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-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= | ||||
| github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= | ||||
| 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.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||
| github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||||
| github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= | ||||
| github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= | ||||
| github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= | ||||
| github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= | ||||
| github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= | ||||
| github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= | ||||
| github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | ||||
| github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | ||||
| github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | ||||
| github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | ||||
| github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||
| github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||
| github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||
| github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= | ||||
| github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= | ||||
| github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= | ||||
| github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= | ||||
| github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg= | ||||
| github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= | ||||
| github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= | ||||
| github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= | ||||
| 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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| 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/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= | ||||
| github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= | ||||
| github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= | ||||
| github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= | ||||
| 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/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/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||||
| github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||||
| 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/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= | ||||
| github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= | ||||
| 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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||
| 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| 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/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= | ||||
| github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | ||||
| 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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||||
| github.com/mattn/go-sqlite3 v1.14.22/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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | ||||
| github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= | ||||
| github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= | ||||
| github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA= | ||||
| github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= | ||||
| github.com/pocketbase/pocketbase v0.22.16 h1:NMpz8s4ASqWGuxzfcpIax1z0WwRzGTSkNjTaisBG5Eo= | ||||
| github.com/pocketbase/pocketbase v0.22.16/go.mod h1:tsEEQ2xXydNUeDUDkgSQDBlIuF0gkhE2tcYZThLCSHg= | ||||
| github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | ||||
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | ||||
| github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= | ||||
| github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= | ||||
| 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||
| github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= | ||||
| github.com/spf13/cast v1.6.0/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/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/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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||
| 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.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.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= | ||||
| github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||
| 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/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= | ||||
| github.com/ulikunitz/xz v0.5.9/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= | ||||
| go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= | ||||
| go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= | ||||
| go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= | ||||
| go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= | ||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= | ||||
| go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= | ||||
| go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= | ||||
| go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= | ||||
| go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= | ||||
| go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= | ||||
| go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= | ||||
| go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||
| gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro= | ||||
| gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco= | ||||
| 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-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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= | ||||
| golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= | ||||
| golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= | ||||
| golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= | ||||
| golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= | ||||
| 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-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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= | ||||
| golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= | ||||
| 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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| 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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||
| golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= | ||||
| golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= | ||||
| 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.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= | ||||
| golang.org/x/oauth2 v0.21.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-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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= | ||||
| golang.org/x/sync v0.7.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-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-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-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-20201119102817-f84b799fce68/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= | ||||
| golang.org/x/sys v0.21.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= | ||||
| golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= | ||||
| 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.3/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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||
| golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= | ||||
| golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | ||||
| golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= | ||||
| golang.org/x/time v0.5.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-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-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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= | ||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= | ||||
| 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-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= | ||||
| golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= | ||||
| google.golang.org/api v0.187.0 h1:Mxs7VATVC2v7CY+7Xwm4ndkX71hpElcvx0D1Ji/p1eo= | ||||
| google.golang.org/api v0.187.0/go.mod h1:KIHlTc4x7N7gKKuVsdmfBXN13yEEWXWFURWY6SBp2gk= | ||||
| 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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||
| google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= | ||||
| 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-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= | ||||
| google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d h1:PksQg4dV6Sem3/HkBX+Ltq8T0ke0PKIRBNBatoDTVls= | ||||
| google.golang.org/genproto v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:s7iA721uChleev562UJO2OYB0PPT9CMFjV+Ce7VJH5M= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE= | ||||
| google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= | ||||
| 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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= | ||||
| 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.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= | ||||
| google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= | ||||
| 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-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= | ||||
| google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= | ||||
| google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= | ||||
| google.golang.org/protobuf v1.22.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.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= | ||||
| google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= | ||||
| google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= | ||||
| gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= | ||||
| gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= | ||||
| gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/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.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= | ||||
| modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= | ||||
| modernc.org/ccgo/v4 v4.17.10 h1:6wrtRozgrhCxieCeJh85QsxkX/2FFrT9hdaWPlbn4Zo= | ||||
| modernc.org/ccgo/v4 v4.17.10/go.mod h1:0NBHgsqTTpm9cA5z2ccErvGZmtntSM9qD2kFAs6pjXM= | ||||
| modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= | ||||
| modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= | ||||
| modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= | ||||
| modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= | ||||
| modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= | ||||
| modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= | ||||
| modernc.org/libc v1.52.1 h1:uau0VoiT5hnR+SpoWekCKbLqm7v6dhRL3hI+NQhgN3M= | ||||
| modernc.org/libc v1.52.1/go.mod h1:HR4nVzFDSDizP620zcMCgjb1/8xk2lg5p/8yjfGv1IQ= | ||||
| modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= | ||||
| modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= | ||||
| 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/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= | ||||
| modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= | ||||
| modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= | ||||
| modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk= | ||||
| modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU= | ||||
| modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= | ||||
| 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/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | ||||
							
								
								
									
										450
									
								
								hub/main.go
									
									
									
									
									
								
							
							
						
						| @@ -1,450 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	_ "beszel/migrations" | ||||
| 	"beszel/site" | ||||
| 	"bytes" | ||||
| 	"crypto/ed25519" | ||||
| 	"encoding/json" | ||||
| 	"encoding/pem" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"net/http/httputil" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/labstack/echo/v5" | ||||
| 	"github.com/pocketbase/pocketbase" | ||||
| 	"github.com/pocketbase/pocketbase/apis" | ||||
| 	"github.com/pocketbase/pocketbase/core" | ||||
| 	"github.com/pocketbase/pocketbase/models" | ||||
| 	"github.com/pocketbase/pocketbase/plugins/migratecmd" | ||||
| 	"github.com/pocketbase/pocketbase/tools/cron" | ||||
| 	"github.com/pocketbase/pocketbase/tools/types" | ||||
| 	"github.com/spf13/cobra" | ||||
| 	"golang.org/x/crypto/ssh" | ||||
| ) | ||||
|  | ||||
| var Version = "0.0.1" | ||||
|  | ||||
| var app *pocketbase.PocketBase | ||||
| var serverConnections = make(map[string]*Server) | ||||
| var serverConnectionsLock = sync.Mutex{} | ||||
|  | ||||
| func main() { | ||||
| 	app = pocketbase.NewWithConfig(pocketbase.Config{ | ||||
| 		DefaultDataDir: "beszel_data", | ||||
| 	}) | ||||
| 	app.RootCmd.Version = Version | ||||
| 	app.RootCmd.Use = "beszel" | ||||
| 	app.RootCmd.Short = "" | ||||
|  | ||||
| 	// add update command | ||||
| 	app.RootCmd.AddCommand(&cobra.Command{ | ||||
| 		Use:   "update", | ||||
| 		Short: "Update beszel to the latest version", | ||||
| 		Run:   updateBeszel, | ||||
| 	}) | ||||
|  | ||||
| 	// loosely check if it was executed using "go run" | ||||
| 	isGoRun := strings.HasPrefix(os.Args[0], os.TempDir()) | ||||
|  | ||||
| 	// // enable auto creation of migration files when making collection changes in the Admin UI | ||||
| 	migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ | ||||
| 		// (the isGoRun check is to enable it only during development) | ||||
| 		Automigrate: isGoRun, | ||||
| 	}) | ||||
|  | ||||
| 	// set auth settings | ||||
| 	app.OnBeforeServe().Add(func(e *core.ServeEvent) error { | ||||
| 		usersCollection, err := app.Dao().FindCollectionByNameOrId("users") | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		usersAuthOptions := usersCollection.AuthOptions() | ||||
| 		usersAuthOptions.AllowUsernameAuth = false | ||||
| 		if os.Getenv("DISABLE_PASSWORD_AUTH") == "true" { | ||||
| 			usersAuthOptions.AllowEmailAuth = false | ||||
| 		} else { | ||||
| 			usersAuthOptions.AllowEmailAuth = true | ||||
| 		} | ||||
| 		usersCollection.SetOptions(usersAuthOptions) | ||||
| 		if err := app.Dao().SaveCollection(usersCollection); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// serve site | ||||
| 	app.OnBeforeServe().Add(func(e *core.ServeEvent) error { | ||||
| 		switch isGoRun { | ||||
| 		case true: | ||||
| 			proxy := httputil.NewSingleHostReverseProxy(&url.URL{ | ||||
| 				Scheme: "http", | ||||
| 				Host:   "localhost:5173", | ||||
| 			}) | ||||
| 			e.Router.GET("/static/*", apis.StaticDirectoryHandler(os.DirFS("./site/public/static"), false)) | ||||
| 			e.Router.Any("/*", echo.WrapHandler(proxy)) | ||||
| 			// e.Router.Any("/", echo.WrapHandler(proxy)) | ||||
| 		default: | ||||
| 			e.Router.GET("/static/*", apis.StaticDirectoryHandler(site.Static, false)) | ||||
| 			e.Router.Any("/*", apis.StaticDirectoryHandler(site.Dist, true)) | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// set up cron jobs / ticker for system updates | ||||
| 	app.OnBeforeServe().Add(func(e *core.ServeEvent) error { | ||||
| 		// 15 second ticker for system updates | ||||
| 		go startSystemUpdateTicker() | ||||
| 		// cron job to delete old records | ||||
| 		scheduler := cron.New() | ||||
| 		scheduler.MustAdd("delete old records", "8 * * * *", func() { | ||||
| 			deleteOldRecords("system_stats", "1m", time.Hour) | ||||
| 			deleteOldRecords("container_stats", "1m", time.Hour) | ||||
| 			deleteOldRecords("system_stats", "10m", 12*time.Hour) | ||||
| 			deleteOldRecords("container_stats", "10m", 12*time.Hour) | ||||
| 			deleteOldRecords("system_stats", "20m", 24*time.Hour) | ||||
| 			deleteOldRecords("container_stats", "20m", 24*time.Hour) | ||||
| 			deleteOldRecords("system_stats", "120m", 7*24*time.Hour) | ||||
| 			deleteOldRecords("container_stats", "120m", 7*24*time.Hour) | ||||
| 			deleteOldRecords("system_stats", "480m", 30*24*time.Hour) | ||||
| 			deleteOldRecords("container_stats", "480m", 30*24*time.Hour) | ||||
| 		}) | ||||
| 		scheduler.Start() | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// ssh key setup | ||||
| 	app.OnBeforeServe().Add(func(e *core.ServeEvent) error { | ||||
| 		// create ssh key if it doesn't exist | ||||
| 		getSSHKey() | ||||
| 		// api route to return 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) | ||||
| 			} | ||||
| 			key, err := os.ReadFile(app.DataDir() + "/id_ed25519.pub") | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			return c.JSON(http.StatusOK, map[string]string{"key": strings.TrimSuffix(string(key), "\n")}) | ||||
| 		}) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// other api routes | ||||
| 	app.OnBeforeServe().Add(func(e *core.ServeEvent) error { | ||||
| 		// check if first time setup on login page | ||||
| 		e.Router.GET("/api/beszel/first-run", func(c echo.Context) error { | ||||
| 			adminNum, err := app.Dao().TotalAdmins() | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			return c.JSON(http.StatusOK, map[string]bool{"firstRun": adminNum == 0}) | ||||
| 		}) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// immediately create connection for new servers | ||||
| 	app.OnModelAfterCreate("systems").Add(func(e *core.ModelEvent) error { | ||||
| 		go updateSystem(e.Model.(*models.Record)) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// do things after a systems record is updated | ||||
| 	app.OnModelAfterUpdate("systems").Add(func(e *core.ModelEvent) error { | ||||
| 		newRecord := e.Model.(*models.Record) | ||||
| 		oldRecord := newRecord.OriginalCopy() | ||||
| 		newStatus := newRecord.Get("status").(string) | ||||
|  | ||||
| 		// if server is disconnected and connection exists, remove it | ||||
| 		if newStatus == "down" || newStatus == "paused" { | ||||
| 			deleteServerConnection(newRecord) | ||||
| 		} | ||||
|  | ||||
| 		// if server is set to pending (unpause), try to connect immediately | ||||
| 		if newStatus == "pending" { | ||||
| 			go updateSystem(newRecord) | ||||
| 		} | ||||
|  | ||||
| 		// alerts | ||||
| 		handleSystemAlerts(newStatus, newRecord, oldRecord) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	// do things after a systems record is deleted | ||||
| 	app.OnModelAfterDelete("systems").Add(func(e *core.ModelEvent) error { | ||||
| 		// if server connection exists, close it | ||||
| 		deleteServerConnection(e.Model.(*models.Record)) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	app.OnModelAfterCreate("system_stats").Add(func(e *core.ModelEvent) error { | ||||
| 		createLongerRecords("system_stats", e.Model.(*models.Record)) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	app.OnModelAfterCreate("container_stats").Add(func(e *core.ModelEvent) error { | ||||
| 		createLongerRecords("container_stats", e.Model.(*models.Record)) | ||||
| 		return nil | ||||
| 	}) | ||||
|  | ||||
| 	if err := app.Start(); err != nil { | ||||
| 		log.Fatal(err) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func startSystemUpdateTicker() { | ||||
| 	ticker := time.NewTicker(15 * time.Second) | ||||
| 	for range ticker.C { | ||||
| 		updateSystems() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func updateSystems() { | ||||
| 	records, err := app.Dao().FindRecordsByFilter( | ||||
| 		"2hz5ncl8tizk5nx",    // collection | ||||
| 		"status != 'paused'", // filter | ||||
| 		"updated",            // sort | ||||
| 		-1,                   // limit | ||||
| 		0,                    // offset | ||||
| 	) | ||||
| 	// log.Println("records", len(records)) | ||||
| 	if err != nil || len(records) == 0 { | ||||
| 		// app.Logger().Error("Failed to query systems") | ||||
| 		return | ||||
| 	} | ||||
| 	fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second) | ||||
| 	batchSize := len(records)/4 + 1 | ||||
| 	for i := 0; i < batchSize; i++ { | ||||
| 		if records[i].Get("updated").(types.DateTime).Time().After(fiftySecondsAgo) { | ||||
| 			break | ||||
| 		} | ||||
| 		// log.Println("updating", records[i].Get(("name"))) | ||||
| 		go updateSystem(records[i]) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func updateSystem(record *models.Record) { | ||||
| 	var server *Server | ||||
| 	// check if server connection data exists | ||||
| 	if _, ok := serverConnections[record.Id]; ok { | ||||
| 		server = serverConnections[record.Id] | ||||
| 	} else { | ||||
| 		// create server connection struct | ||||
| 		server = &Server{ | ||||
| 			Host: record.Get("host").(string), | ||||
| 			Port: record.Get("port").(string), | ||||
| 		} | ||||
| 		client, err := getServerConnection(server) | ||||
| 		if err != nil { | ||||
| 			app.Logger().Error("Failed to connect:", "err", err.Error(), "server", server.Host, "port", server.Port) | ||||
| 			updateServerStatus(record, "down") | ||||
| 			return | ||||
| 		} | ||||
| 		server.Client = client | ||||
| 		serverConnectionsLock.Lock() | ||||
| 		serverConnections[record.Id] = server | ||||
| 		serverConnectionsLock.Unlock() | ||||
| 	} | ||||
| 	// get server stats from agent | ||||
| 	systemData, err := requestJson(server) | ||||
| 	if err != nil { | ||||
| 		if err.Error() == "retry" { | ||||
| 			// if previous connection was closed, try again | ||||
| 			app.Logger().Error("Existing SSH connection closed. Retrying...", "host", server.Host, "port", server.Port) | ||||
| 			deleteServerConnection(record) | ||||
| 			updateSystem(record) | ||||
| 			return | ||||
| 		} | ||||
| 		app.Logger().Error("Failed to get server stats: ", "err", err.Error()) | ||||
| 		updateServerStatus(record, "down") | ||||
| 		return | ||||
| 	} | ||||
| 	// update system record | ||||
| 	record.Set("status", "up") | ||||
| 	record.Set("info", systemData.Info) | ||||
| 	if err := app.Dao().SaveRecord(record); err != nil { | ||||
| 		app.Logger().Error("Failed to update record: ", "err", err.Error()) | ||||
| 	} | ||||
| 	// add new system_stats record | ||||
| 	system_stats, _ := app.Dao().FindCollectionByNameOrId("system_stats") | ||||
| 	system_stats_record := models.NewRecord(system_stats) | ||||
| 	system_stats_record.Set("system", record.Id) | ||||
| 	system_stats_record.Set("stats", systemData.Stats) | ||||
| 	system_stats_record.Set("type", "1m") | ||||
| 	if err := app.Dao().SaveRecord(system_stats_record); err != nil { | ||||
| 		app.Logger().Error("Failed to save record: ", "err", err.Error()) | ||||
| 	} | ||||
| 	// add new container_stats record | ||||
| 	if len(systemData.Containers) > 0 { | ||||
| 		container_stats, _ := app.Dao().FindCollectionByNameOrId("container_stats") | ||||
| 		container_stats_record := models.NewRecord(container_stats) | ||||
| 		container_stats_record.Set("system", record.Id) | ||||
| 		container_stats_record.Set("stats", systemData.Containers) | ||||
| 		container_stats_record.Set("type", "1m") | ||||
| 		if err := app.Dao().SaveRecord(container_stats_record); err != nil { | ||||
| 			app.Logger().Error("Failed to save record: ", "err", err.Error()) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // set server to status down and close connection | ||||
| func updateServerStatus(record *models.Record, status string) { | ||||
| 	// if in map, close connection and remove from map | ||||
| 	// this is now down automatically in an after update hook | ||||
| 	// if status == "down" || status == "paused" { | ||||
| 	// 	deleteServerConnection(record) | ||||
| 	// } | ||||
| 	if record.Get("status") != status { | ||||
| 		record.Set("status", status) | ||||
| 		if err := app.Dao().SaveRecord(record); err != nil { | ||||
| 			app.Logger().Error("Failed to update record: ", "err", err.Error()) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func deleteServerConnection(record *models.Record) { | ||||
| 	if _, ok := serverConnections[record.Id]; ok { | ||||
| 		if serverConnections[record.Id].Client != nil { | ||||
| 			serverConnections[record.Id].Client.Close() | ||||
| 		} | ||||
| 		serverConnectionsLock.Lock() | ||||
| 		defer serverConnectionsLock.Unlock() | ||||
| 		delete(serverConnections, record.Id) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func getServerConnection(server *Server) (*ssh.Client, error) { | ||||
| 	// app.Logger().Debug("new ssh connection", "server", server.Host) | ||||
| 	key, err := getSSHKey() | ||||
| 	if err != nil { | ||||
| 		app.Logger().Error("Failed to get SSH key: ", "err", err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	time.Sleep(time.Second) | ||||
|  | ||||
| 	// Create the Signer for this private key. | ||||
| 	signer, err := ssh.ParsePrivateKey(key) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	config := &ssh.ClientConfig{ | ||||
| 		User: "u", | ||||
| 		Auth: []ssh.AuthMethod{ | ||||
| 			ssh.PublicKeys(signer), | ||||
| 		}, | ||||
| 		HostKeyCallback: ssh.InsecureIgnoreHostKey(), | ||||
| 		Timeout:         5 * time.Second, | ||||
| 	} | ||||
|  | ||||
| 	client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", server.Host, server.Port), config) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return client, nil | ||||
| } | ||||
|  | ||||
| func requestJson(server *Server) (SystemData, error) { | ||||
| 	session, err := server.Client.NewSession() | ||||
| 	if err != nil { | ||||
| 		return SystemData{}, errors.New("retry") | ||||
| 	} | ||||
| 	defer session.Close() | ||||
|  | ||||
| 	// Create a buffer to capture the output | ||||
| 	var outputBuffer bytes.Buffer | ||||
| 	session.Stdout = &outputBuffer | ||||
|  | ||||
| 	if err := session.Shell(); err != nil { | ||||
| 		return SystemData{}, err | ||||
| 	} | ||||
|  | ||||
| 	err = session.Wait() | ||||
| 	if err != nil { | ||||
| 		return SystemData{}, err | ||||
| 	} | ||||
|  | ||||
| 	// Unmarshal the output into our struct | ||||
| 	var systemData SystemData | ||||
| 	err = json.Unmarshal(outputBuffer.Bytes(), &systemData) | ||||
| 	if err != nil { | ||||
| 		return SystemData{}, err | ||||
| 	} | ||||
|  | ||||
| 	return systemData, nil | ||||
| } | ||||
|  | ||||
| func getSSHKey() ([]byte, error) { | ||||
| 	dataDir := app.DataDir() | ||||
| 	// check if the key pair already exists | ||||
| 	existingKey, err := os.ReadFile(dataDir + "/id_ed25519") | ||||
| 	if err == nil { | ||||
| 		return existingKey, nil | ||||
| 	} | ||||
|  | ||||
| 	// Generate the Ed25519 key pair | ||||
| 	pubKey, privKey, err := ed25519.GenerateKey(nil) | ||||
| 	if err != nil { | ||||
| 		// app.Logger().Error("Error generating key pair:", "err", err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Get the private key in OpenSSH format | ||||
| 	privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "") | ||||
| 	if err != nil { | ||||
| 		// app.Logger().Error("Error marshaling private key:", "err", err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Save the private key to a file | ||||
| 	privateFile, err := os.Create(dataDir + "/id_ed25519") | ||||
| 	if err != nil { | ||||
| 		// app.Logger().Error("Error creating private key file:", "err", err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer privateFile.Close() | ||||
|  | ||||
| 	if err := pem.Encode(privateFile, privKeyBytes); err != nil { | ||||
| 		// app.Logger().Error("Error writing private key to file:", "err", err.Error()) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// Generate the public key in OpenSSH format | ||||
| 	publicKey, err := ssh.NewPublicKey(pubKey) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey) | ||||
|  | ||||
| 	// Save the public key to a file | ||||
| 	publicFile, err := os.Create(dataDir + "/id_ed25519.pub") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer publicFile.Close() | ||||
|  | ||||
| 	if _, err := publicFile.Write(pubKeyBytes); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	app.Logger().Info("ed25519 SSH key pair generated successfully.") | ||||
| 	app.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519") | ||||
| 	app.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub") | ||||
|  | ||||
| 	existingKey, err = os.ReadFile(dataDir + "/id_ed25519") | ||||
| 	if err == nil { | ||||
| 		return existingKey, nil | ||||
| 	} | ||||
| 	return nil, err | ||||
| } | ||||
| @@ -1,418 +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-07-22 19:39:17.434Z", | ||||
| 				"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": true, | ||||
| 						"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": true, | ||||
| 						"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-07-18 15:56:45.302Z", | ||||
| 				"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-07-18 15:57:50.933Z", | ||||
| 				"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-07-22 20:10:20.670Z", | ||||
| 				"name": "users", | ||||
| 				"type": "auth", | ||||
| 				"system": false, | ||||
| 				"schema": [ | ||||
| 					{ | ||||
| 						"system": false, | ||||
| 						"id": "qkbp58ae", | ||||
| 						"name": "role", | ||||
| 						"type": "select", | ||||
| 						"required": true, | ||||
| 						"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-07-22 19:13:16.498Z", | ||||
| 				"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" | ||||
| 							] | ||||
| 						} | ||||
| 					}, | ||||
| 					{ | ||||
| 						"system": false, | ||||
| 						"id": "o2ablxvn", | ||||
| 						"name": "value", | ||||
| 						"type": "number", | ||||
| 						"required": false, | ||||
| 						"presentable": false, | ||||
| 						"unique": false, | ||||
| 						"options": { | ||||
| 							"min": null, | ||||
| 							"max": null, | ||||
| 							"noDecimal": false | ||||
| 						} | ||||
| 					}, | ||||
| 					{ | ||||
| 						"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": {} | ||||
| 			} | ||||
| 		]` | ||||
|  | ||||
| 		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 | ||||
| 	}) | ||||
| } | ||||
| @@ -1,19 +0,0 @@ | ||||
| package migrations | ||||
|  | ||||
| import ( | ||||
| 	"github.com/pocketbase/dbx" | ||||
| 	"github.com/pocketbase/pocketbase/daos" | ||||
| 	m "github.com/pocketbase/pocketbase/migrations" | ||||
| ) | ||||
|  | ||||
| func init() { | ||||
| 	m.Register(func(db dbx.Builder) error { | ||||
| 		dao := daos.New(db) | ||||
|  | ||||
| 		settings, _ := dao.FindSettings() | ||||
| 		settings.Meta.AppName = "Beszel" | ||||
| 		settings.Meta.HideControls = true | ||||
|  | ||||
| 		return dao.SaveSettings(settings) | ||||
| 	}, nil) | ||||
| } | ||||
							
								
								
									
										153
									
								
								hub/records.go
									
									
									
									
									
								
							
							
						
						| @@ -1,153 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"reflect" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/pocketbase/dbx" | ||||
| 	"github.com/pocketbase/pocketbase/models" | ||||
| 	"github.com/pocketbase/pocketbase/tools/types" | ||||
| ) | ||||
|  | ||||
| func createLongerRecords(collectionName string, shorterRecord *models.Record) { | ||||
| 	shorterRecordType := shorterRecord.Get("type").(string) | ||||
| 	systemId := shorterRecord.Get("system").(string) | ||||
| 	// fmt.Println("create longer records", "recordType", shorterRecordType, "systemId", systemId) | ||||
| 	var longerRecordType string | ||||
| 	var timeAgo time.Duration | ||||
| 	var expectedShorterRecords int | ||||
| 	switch shorterRecordType { | ||||
| 	case "1m": | ||||
| 		longerRecordType = "10m" | ||||
| 		timeAgo = -10 * time.Minute | ||||
| 		expectedShorterRecords = 10 | ||||
| 	case "10m": | ||||
| 		longerRecordType = "20m" | ||||
| 		timeAgo = -20 * time.Minute | ||||
| 		expectedShorterRecords = 2 | ||||
| 	case "20m": | ||||
| 		longerRecordType = "120m" | ||||
| 		timeAgo = -120 * time.Minute | ||||
| 		expectedShorterRecords = 6 | ||||
| 	default: | ||||
| 		longerRecordType = "480m" | ||||
| 		timeAgo = -480 * time.Minute | ||||
| 		expectedShorterRecords = 4 | ||||
| 	} | ||||
|  | ||||
| 	longerRecordPeriod := time.Now().UTC().Add(timeAgo + 10*time.Second).Format("2006-01-02 15:04:05") | ||||
| 	// check creation time of last 10m record | ||||
| 	lastLongerRecord, err := app.Dao().FindFirstRecordByFilter( | ||||
| 		collectionName, | ||||
| 		"type = {:type} && system = {:system} && created > {:created}", | ||||
| 		dbx.Params{"type": longerRecordType, "system": systemId, "created": longerRecordPeriod}, | ||||
| 	) | ||||
| 	// return if longer record exists | ||||
| 	if err == nil || lastLongerRecord != nil { | ||||
| 		// log.Println("longer record found. returning") | ||||
| 		return | ||||
| 	} | ||||
| 	// get shorter records from the past x minutes | ||||
| 	// shorterRecordPeriod := time.Now().UTC().Add(timeAgo + time.Second).Format("2006-01-02 15:04:05") | ||||
| 	allShorterRecords, err := app.Dao().FindRecordsByFilter( | ||||
| 		collectionName, | ||||
| 		"type = {:type} && system = {:system} && created > {:created}", | ||||
| 		"-created", | ||||
| 		-1, | ||||
| 		0, | ||||
| 		dbx.Params{"type": shorterRecordType, "system": systemId, "created": longerRecordPeriod}, | ||||
| 	) | ||||
| 	// return if not enough shorter records | ||||
| 	if err != nil || len(allShorterRecords) < expectedShorterRecords { | ||||
| 		// log.Println("not enough shorter records. returning") | ||||
| 		return | ||||
| 	} | ||||
| 	// average the shorter records and create longer record | ||||
| 	var stats interface{} | ||||
| 	switch collectionName { | ||||
| 	case "system_stats": | ||||
| 		stats = averageSystemStats(allShorterRecords) | ||||
| 	case "container_stats": | ||||
| 		stats = averageContainerStats(allShorterRecords) | ||||
| 	} | ||||
| 	collection, _ := app.Dao().FindCollectionByNameOrId(collectionName) | ||||
| 	tenMinRecord := models.NewRecord(collection) | ||||
| 	tenMinRecord.Set("system", systemId) | ||||
| 	tenMinRecord.Set("stats", stats) | ||||
| 	tenMinRecord.Set("type", longerRecordType) | ||||
| 	if err := app.Dao().SaveRecord(tenMinRecord); err != nil { | ||||
| 		fmt.Println("failed to save longer record", "err", err.Error()) | ||||
| 	} | ||||
|  | ||||
| } | ||||
|  | ||||
| // calculate the average stats of a list of system_stats records | ||||
| func averageSystemStats(records []*models.Record) SystemStats { | ||||
| 	count := float64(len(records)) | ||||
| 	sum := reflect.New(reflect.TypeOf(SystemStats{})).Elem() | ||||
|  | ||||
| 	for _, record := range records { | ||||
| 		var stats SystemStats | ||||
| 		json.Unmarshal([]byte(record.Get("stats").(types.JsonRaw)), &stats) | ||||
| 		statValue := reflect.ValueOf(stats) | ||||
| 		for i := 0; i < statValue.NumField(); i++ { | ||||
| 			field := sum.Field(i) | ||||
| 			field.SetFloat(field.Float() + statValue.Field(i).Float()) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	average := reflect.New(reflect.TypeOf(SystemStats{})).Elem() | ||||
| 	for i := 0; i < sum.NumField(); i++ { | ||||
| 		average.Field(i).SetFloat(twoDecimals(sum.Field(i).Float() / count)) | ||||
| 	} | ||||
|  | ||||
| 	return average.Interface().(SystemStats) | ||||
| } | ||||
|  | ||||
| // calculate the average stats of a list of container_stats records | ||||
| func averageContainerStats(records []*models.Record) (stats []ContainerStats) { | ||||
| 	sums := make(map[string]*ContainerStats) | ||||
| 	count := float64(len(records)) | ||||
| 	for _, record := range records { | ||||
| 		var stats []ContainerStats | ||||
| 		json.Unmarshal([]byte(record.Get("stats").(types.JsonRaw)), &stats) | ||||
| 		for _, stat := range stats { | ||||
| 			if _, ok := sums[stat.Name]; !ok { | ||||
| 				sums[stat.Name] = &ContainerStats{Name: stat.Name, Cpu: 0, Mem: 0} | ||||
| 			} | ||||
| 			sums[stat.Name].Cpu += stat.Cpu | ||||
| 			sums[stat.Name].Mem += stat.Mem | ||||
| 		} | ||||
| 	} | ||||
| 	for _, value := range sums { | ||||
| 		stats = append(stats, ContainerStats{ | ||||
| 			Name: value.Name, | ||||
| 			Cpu:  twoDecimals(value.Cpu / count), | ||||
| 			Mem:  twoDecimals(value.Mem / count), | ||||
| 		}) | ||||
| 	} | ||||
| 	return stats | ||||
| } | ||||
|  | ||||
| /* Round float to two decimals */ | ||||
| func twoDecimals(value float64) float64 { | ||||
| 	return math.Round(value*100) / 100 | ||||
| } | ||||
|  | ||||
| /* Delete records of specified collection and type that are older than timeLimit */ | ||||
| func deleteOldRecords(collection string, recordType string, timeLimit time.Duration) { | ||||
| 	timeLimitStamp := time.Now().UTC().Add(-timeLimit).Format("2006-01-02 15:04:05") | ||||
| 	records, _ := app.Dao().FindRecordsByExpr(collection, | ||||
| 		dbx.NewExp("type = {:type}", dbx.Params{"type": recordType}), | ||||
| 		dbx.NewExp("created < {:created}", dbx.Params{"created": timeLimitStamp}), | ||||
| 	) | ||||
| 	for _, record := range records { | ||||
| 		if err := app.Dao().DeleteRecord(record); err != nil { | ||||
| 			log.Fatal(err) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| { | ||||
|   "$schema": "https://ui.shadcn.com/schema.json", | ||||
|   "style": "default", | ||||
|   "rsc": false, | ||||
|   "tsx": true, | ||||
|   "tailwind": { | ||||
|     "config": "tailwind.config.js", | ||||
|     "css": "src/index.css", | ||||
|     "baseColor": "gray", | ||||
|     "cssVariables": true, | ||||
|     "prefix": "" | ||||
|   }, | ||||
|   "aliases": { | ||||
|     "components": "@/components", | ||||
|     "utils": "@/lib/utils" | ||||
|   } | ||||
| } | ||||
| @@ -1,15 +0,0 @@ | ||||
| // Package site handles the Beszel frontend embedding. | ||||
| package site | ||||
|  | ||||
| import ( | ||||
| 	"embed" | ||||
|  | ||||
| 	"github.com/labstack/echo/v5" | ||||
| ) | ||||
|  | ||||
| //go:embed all:dist | ||||
| var assets embed.FS | ||||
|  | ||||
| var Dist = echo.MustSubFS(assets, "dist") | ||||
|  | ||||
| var Static = echo.MustSubFS(assets, "dist/static") | ||||
| @@ -1,19 +0,0 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| 	<head> | ||||
| 		<meta charset="UTF-8" /> | ||||
| 		<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> | ||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
| 		<title>Beszel</title> | ||||
| 		<link rel="preconnect" href="https://fonts.googleapis.com" /> | ||||
| 		<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||||
| 		<link | ||||
| 			href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" | ||||
| 			rel="stylesheet" | ||||
| 		/> | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<div id="app"></div> | ||||
| 		<script type="module" src="/src/main.tsx"></script> | ||||
| 	</body> | ||||
| </html> | ||||
| @@ -1,52 +0,0 @@ | ||||
| { | ||||
|   "name": "site", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@nanostores/react": "^0.7.2", | ||||
|     "@nanostores/router": "^0.15.0", | ||||
|     "@radix-ui/react-alert-dialog": "^1.1.1", | ||||
|     "@radix-ui/react-dialog": "^1.1.1", | ||||
|     "@radix-ui/react-dropdown-menu": "^2.1.1", | ||||
|     "@radix-ui/react-label": "^2.1.0", | ||||
|     "@radix-ui/react-select": "^2.1.1", | ||||
|     "@radix-ui/react-separator": "^1.1.0", | ||||
|     "@radix-ui/react-slider": "^1.2.0", | ||||
|     "@radix-ui/react-slot": "^1.1.0", | ||||
|     "@radix-ui/react-switch": "^1.1.0", | ||||
|     "@radix-ui/react-toast": "^1.2.1", | ||||
|     "@radix-ui/react-tooltip": "^1.1.2", | ||||
|     "@tanstack/react-table": "^8.19.2", | ||||
|     "@vitejs/plugin-react": "^4.3.1", | ||||
|     "class-variance-authority": "^0.7.0", | ||||
|     "clsx": "^2.1.1", | ||||
|     "cmdk": "^1.0.0", | ||||
|     "d3-scale": "^4.0.2", | ||||
|     "d3-time": "^3.1.0", | ||||
|     "lucide-react": "^0.407.0", | ||||
|     "nanostores": "^0.10.3", | ||||
|     "pocketbase": "^0.21.3", | ||||
|     "react": "^18.3.1", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "recharts": "^2.13.0-alpha.1", | ||||
|     "tailwind-merge": "^2.4.0", | ||||
|     "tailwindcss-animate": "^1.0.7", | ||||
|     "valibot": "^0.36.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/bun": "^1.1.6", | ||||
|     "@types/react": "^18.3.3", | ||||
|     "@types/react-dom": "^18.3.0", | ||||
|     "autoprefixer": "^10.4.19", | ||||
|     "postcss": "^8.4.39", | ||||
|     "tailwindcss": "^3.4.4", | ||||
|     "typescript": "^5.5.3", | ||||
|     "vite": "^5.3.3" | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| export default { | ||||
|   plugins: { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.2 6.9c-1 0-2.5-1-4-1-2 0-4 1.1-5 3-2 3.6-.5 9 1.5 12 1 1.5 2.3 3.2 3.8 3.1 1.6 0 2.1-1 4-1 1.8 0 2.3 1 4 1 1.6 0 2.6-1.5 3.6-3a13 13 0 0 0 1.7-3.4 5.3 5.3 0 0 1-.6-9.4 5.6 5.6 0 0 0-4.4-2.4C14.8 5.6 13 7 12.2 7zm3.3-3c.9-1 1.4-2.5 1.3-3.9-1.2 0-2.7.8-3.6 1.8A5 5 0 0 0 12 5.5c1.3.1 2.7-.7 3.5-1.7"/></svg> | ||||
| Before Width: | Height: | Size: 378 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M.8 1.2a.8.8 0 0 0-.8 1l3.3 19.7c0 .5.5.9 1 .9h15.6a.8.8 0 0 0 .8-.7l3.3-20a.8.8 0 0 0-.8-.9zm13.7 14.3h-5l-1.3-7h7.5z"/></svg> | ||||
| Before Width: | Height: | Size: 196 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.3 4.4a19.8 19.8 0 0 0-4.9-1.5L14.7 4C13 4 11.1 4 9.3 4.1L8.6 3a19.7 19.7 0 0 0-5 1.5C.6 9-.4 13.6.1 18.1c2 1.5 4 2.4 6 3h.1c.5-.6.9-1.3 1.2-2l-1.9-1V18l.4-.3c4 1.8 8.2 1.8 12.1 0h.1l.4.3v.1a12.3 12.3 0 0 1-2 1l1.3 2c2-.6 4-1.5 6-3h.1c.5-5.2-.8-9.7-3.6-13.7zM8 15.4c-1.2 0-2.1-1.2-2.1-2.5s1-2.4 2.1-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4zm8 0c-1.2 0-2.2-1.2-2.2-2.5s1-2.4 2.2-2.4c1.2 0 2.2 1 2.2 2.4 0 1.3-1 2.4-2.2 2.4Z"/></svg> | ||||
| Before Width: | Height: | Size: 506 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.1 23.7v-8H6.6V12h2.5v-1.5c0-4.1 1.8-6 5.9-6h1.4a8.7 8.7 0 0 1 1.2.3V8a8.6 8.6 0 0 0-.7 0 26.8 26.8 0 0 0-.7 0c-.7 0-1.3 0-1.7.3a1.7 1.7 0 0 0-.7.6c-.2.4-.3 1-.3 1.7V12h3.9l-.4 2.1-.3 1.6h-3.2V24a12 12 0 1 0-4.4-.3Z"/></svg> | ||||
| Before Width: | Height: | Size: 295 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.2 4.6a4.2 4.2 0 0 0-2.9 1.1C-.4 7.3 0 9.7.1 10.1c0 .4.3 1.6 1.2 2.7C3 15 6.8 15 6.8 15S7.3 16 8 17c1 1.3 2 2.3 2.9 2.4H18s.4 0 1-.4c.6-.3 1-.9 1-.9s.6-.5 1.3-1.7l.5-1s2.1-4.6 2.1-9c0-1.2-.4-1.5-.4-1.5l-.4-.2s-4.5.3-6.8.3h-1.5v4.5l-.6-.3V5h-3.5l-6-.4h-.6zm.4 1.8s.3 2.3.7 3.6c.2 1.1 1 3 1 3l-1.7-.3c-1-.4-1.4-.8-1.4-.8s-.8-.5-1.1-1.5c-.7-1.7 0-2.7 0-2.7s.2-.9 1.4-1.1c.4-.2.9-.2 1-.2zM12.9 9l.5.1.9.4-.6 1.1a.7.7 0 0 0-.6.4.7.7 0 0 0 .1.7l-1 2a.7.7 0 0 0-.6.5.7.7 0 0 0 .3.7.7.7 0 0 0 1-.2.7.7 0 0 0-.2-.8l1-2a.7.7 0 0 0 .2 0 .7.7 0 0 0 .3 0 8.8 8.8 0 0 1 1 .4.8.8 0 0 1 .3.3l-.1.6c0 .3-.7 1.5-.7 1.5a.7.7 0 0 0-.7.5.7.7 0 1 0 1.2-.2l.2-.5.5-1.1c0-.1.2-.4.1-.8a1 1 0 0 0-.5-.7l-1-.6-.1-.2a.7.7 0 0 0-.2-.3l.5-1 3 1.4s.4.2.5.6v.6L16 16.8s-.2.5-.7.5a1 1 0 0 1-.4 0h-.2L10.4 15s-.4-.2-.5-.6l.1-.7 2-4.2s.3-.4.5-.5A.9.9 0 0 1 13 9z"/></svg> | ||||
| Before Width: | Height: | Size: 907 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm6 5.3c.4 0 .7.3.7.6v1.5a.6.6 0 0 1-.6.6H9.8C8.8 8 8 8.8 8 9.8v5.6c0 .3.3.6.6.6h5.6c1 0 1.8-.8 1.8-1.8V14a.6.6 0 0 0-.6-.6h-4.1a.6.6 0 0 1-.6-.6v-1.4a.6.6 0 0 1 .6-.6H18c.3 0 .6.2.6.6v3.4a4 4 0 0 1-4 4H5.9a.6.6 0 0 1-.6-.6V9.8a4.4 4.4 0 0 1 4.5-4.5H18Z"/></svg> | ||||
| Before Width: | Height: | Size: 406 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2 0 1.9 1.2 1.9 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/></svg> | ||||
| Before Width: | Height: | Size: 470 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.6 9.6 20.3 1a.9.9 0 0 0-.3-.4.9.9 0 0 0-1 0 .9.9 0 0 0-.3.5l-2.2 6.7h-9L5.3 1.1A.9.9 0 0 0 5 .6a.9.9 0 0 0-1 0 .9.9 0 0 0-.3.4L.4 9.5a6 6 0 0 0 2 7.1l5 3.8 2.5 1.8 1.5 1.1a1 1 0 0 0 1.2 0l1.5-1 2.5-2 5-3.7a6 6 0 0 0 2-7z"/></svg> | ||||
| Before Width: | Height: | Size: 302 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.5 11v3.2h7.8a7 7 0 0 1-1.8 4.1 8 8 0 0 1-6 2.4c-4.8 0-8.6-3.9-8.6-8.7a8.6 8.6 0 0 1 14.5-6.4l2.3-2.3C18.7 1.4 16 0 12.5 0 5.9 0 .3 5.4.3 12S6 24 12.5 24a11 11 0 0 0 8.4-3.4c2.1-2.1 2.8-5.2 2.8-7.6 0-.8 0-1.5-.2-2h-11z"/></svg> | ||||
| Before Width: | Height: | Size: 299 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 0C5.8.2 5 .4 4.1.7 3.3 1 2.7 1.4 2 2c-.7.7-1 1.4-1.4 2.2C.3 4.9.1 5.8.1 7a84.6 84.6 0 0 0 .5 12.8c.4.8.8 1.4 1.4 2.1.7.7 1.4 1 2.2 1.4.7.3 1.6.5 2.9.5a85 85 0 0 0 12.8-.5c.8-.4 1.4-.8 2.1-1.4.7-.7 1-1.4 1.4-2.2.3-.7.5-1.6.5-2.9a85 85 0 0 0-.5-12.8C23 3.3 22.6 2.7 22 2c-.7-.7-1.4-1-2.2-1.4-.7-.3-1.6-.5-2.9-.5A85.5 85.5 0 0 0 7 0m.2 21.7c-1.2 0-1.8-.3-2.3-.4-.5-.2-1-.5-1.3-1-.5-.3-.7-.7-1-1.3-.1-.4-.3-1-.4-2.2a84.8 84.8 0 0 1 .4-12c.2-.5.5-1 1-1.3.3-.5.7-.7 1.3-1 .4-.1 1-.3 2.2-.4a84.4 84.4 0 0 1 12 .4c.5.3 1 .5 1.3 1 .5.3.7.7 1 1.3.1.4.3 1 .4 2.2a82.7 82.7 0 0 1-.4 12c-.2.5-.5 1-1 1.3-.3.5-.7.7-1.3 1-.4.1-1 .3-2.2.4a84.9 84.9 0 0 1-9.7 0M17 5.6A1.4 1.4 0 1 0 18.4 4 1.4 1.4 0 0 0 17 5.6M5.8 12a6.2 6.2 0 1 0 12.4 0 6.2 6.2 0 0 0-12.4 0M8 12a4 4 0 1 1 4 4 4 4 0 0 1-4-4"/></svg> | ||||
| Before Width: | Height: | Size: 856 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> | ||||
| Before Width: | Height: | Size: 257 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.5.9 11 2.7v18.1c-4.1-.5-7.3-2.7-7.3-5.5 0-2.5 2.8-4.7 6.7-5.4V7.6C4.4 8.3 0 11.5 0 15.3c0 4 4.7 7.3 11 7.8l3.5-1.7V.9m.7 6.7V10c1.4.3 2.7.7 3.7 1.3l-2 1.1L24 14l-.5-5.2-1.9 1c-1.7-1-4-1.8-6.4-2z"/></svg> | ||||
| Before Width: | Height: | Size: 276 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 7.2c0-3-2.4-5.6-5.2-6.5-3.5-1.1-8.1-1-11.4.6-4 2-5.3 6-5.4 10.2C1 15 1.3 24 6.4 24c3.8 0 4.3-4.8 6-7.1 1.3-1.7 3-2.2 4.9-2.7a7.1 7.1 0 0 0 5.7-7Z"/></svg> | ||||
| Before Width: | Height: | Size: 227 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.7 0 12 0zm5.5 17.3c-.2.4-.6.5-1 .3-2.8-1.8-6.4-2.1-10.6-1.2-.4.2-.7-.1-.9-.5 0-.4.2-.8.6-.9 4.5-1 8.5-.6 11.6 1.3.4.2.5.7.3 1zM19 14c-.3.5-.9.6-1.3.3-3.2-2-8.2-2.5-12-1.3-.4 0-1-.2-1-.6-.2-.5 0-1 .5-1.2 4.4-1.3 9.8-.6 13.5 1.6.4.2.6.8.3 1.2zm0-3.3A19.9 19.9 0 0 0 5.3 9.3c-.6.2-1.2-.2-1.4-.7-.2-.6.2-1.2.7-1.4 4.3-1.3 11.3-1 15.7 1.6.6.3.7 1 .4 1.6-.3.4-1 .6-1.5.3z"/></svg> | ||||
| Before Width: | Height: | Size: 495 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m15.4 18-2.1-4.2h-3l5 10.2 5.2-10.2h-3m-7-5.6 2.8 5.6h4.2L10.5 0l-7 13.8h4.1"/></svg> | ||||
| Before Width: | Height: | Size: 154 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.6 4.7h1.7V10h-1.7zm4.7 0H18V10h-1.7zM6 0 1.7 4.3v15.4H7V24l4.2-4.3h3.5l7.7-7.7V0zm14.6 11.1L17 14.6h-3.4l-3 3v-3H7V1.7h13.7Z"/></svg> | ||||
| Before Width: | Height: | Size: 206 B | 
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M22.5 6c-.8.3-1.6.6-2.5.7.9-.5 1.6-1.4 1.9-2.4-.8.5-1.8.9-2.7 1a4.3 4.3 0 0 0-7.3 4C8.2 9 5 7.3 3 4.8a4.2 4.2 0 0 0 1.3 5.7c-.7 0-1.3-.2-2-.5 0 2.1 1.6 3.8 3.5 4.2a4.2 4.2 0 0 1-2 .1 4.3 4.3 0 0 0 4 3A8.5 8.5 0 0 1 2.7 19h-1A12.1 12.1 0 0 0 20.3 8.8v-.6c.8-.6 1.5-1.3 2-2.2"/></svg> | ||||
| Before Width: | Height: | Size: 371 B | 
| @@ -1,166 +0,0 @@ | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { | ||||
| 	Dialog, | ||||
| 	DialogContent, | ||||
| 	DialogDescription, | ||||
| 	DialogFooter, | ||||
| 	DialogHeader, | ||||
| 	DialogTitle, | ||||
| 	DialogTrigger, | ||||
| } from '@/components/ui/dialog' | ||||
| import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' | ||||
|  | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Label } from '@/components/ui/label' | ||||
| import { $publicKey, pb } from '@/lib/stores' | ||||
| import { Copy, Plus } from 'lucide-react' | ||||
| import { useState, useRef, MutableRefObject, useEffect } from 'react' | ||||
| import { useStore } from '@nanostores/react' | ||||
| import { copyToClipboard } from '@/lib/utils' | ||||
| import { SystemStats } from '@/types' | ||||
|  | ||||
| export function AddSystemButton() { | ||||
| 	const [open, setOpen] = useState(false) | ||||
| 	const port = useRef() as MutableRefObject<HTMLInputElement> | ||||
| 	const publicKey = useStore($publicKey) | ||||
|  | ||||
| 	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 | ||||
|     environment: | ||||
|       PORT: ${port} | ||||
|       KEY: "${publicKey}" | ||||
|       # FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats`) | ||||
| 	} | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (publicKey || !open) { | ||||
| 			return | ||||
| 		} | ||||
| 		// get public key | ||||
| 		pb.send('/api/beszel/getkey', {}).then(({ key }) => { | ||||
| 			$publicKey.set(key) | ||||
| 		}) | ||||
| 	}, [open]) | ||||
|  | ||||
| 	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.status = 'pending' | ||||
| 		data.users = pb.authStore.model!.id | ||||
| 		data.info = { | ||||
| 			cpu: 0, | ||||
| 			m: 0, | ||||
| 			mu: 0, | ||||
| 			mp: 0, | ||||
| 			mb: 0, | ||||
| 			d: 0, | ||||
| 			du: 0, | ||||
| 			dp: 0, | ||||
| 			dr: 0, | ||||
| 			dw: 0, | ||||
| 		} as SystemStats | ||||
| 		try { | ||||
| 			setOpen(false) | ||||
| 			await pb.collection('systems').create(data) | ||||
| 			// console.log(record) | ||||
| 		} catch (e) { | ||||
| 			console.log(e) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<Dialog open={open} onOpenChange={setOpen}> | ||||
| 			<DialogTrigger asChild> | ||||
| 				<Button variant="outline" className="flex gap-1"> | ||||
| 					<Plus className="h-4 w-4 mr-auto" /> | ||||
| 					Add <span className="hidden sm:inline">System</span> | ||||
| 				</Button> | ||||
| 			</DialogTrigger> | ||||
| 			<DialogContent className="w-[90%] sm:max-w-[425px] rounded-lg"> | ||||
| 				<DialogHeader> | ||||
| 					<DialogTitle className="mb-2">Add New System</DialogTitle> | ||||
| 					<DialogDescription> | ||||
| 						The agent must be running on the server to connect. Copy the{' '} | ||||
| 						<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the agent | ||||
| 						below. | ||||
| 					</DialogDescription> | ||||
| 				</DialogHeader> | ||||
| 				<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-right"> | ||||
| 								Name | ||||
| 							</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-right"> | ||||
| 								Host / IP | ||||
| 							</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-right"> | ||||
| 								Port | ||||
| 							</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-right whitespace-pre"> | ||||
| 								Public Key | ||||
| 							</Label> | ||||
| 							<Input readOnly id="pkey" value={publicKey} className="col-span-3" required></Input> | ||||
| 							<div | ||||
| 								className={ | ||||
| 									'h-6 w-24 bg-gradient-to-r from-transparent to-background to-65% absolute right-1 pointer-events-none' | ||||
| 								} | ||||
| 							></div> | ||||
| 							<TooltipProvider delayDuration={100}> | ||||
| 								<Tooltip> | ||||
| 									<TooltipTrigger asChild> | ||||
| 										<Button | ||||
| 											type="button" | ||||
| 											variant={'link'} | ||||
| 											className="absolute right-0" | ||||
| 											onClick={() => copyToClipboard(publicKey)} | ||||
| 										> | ||||
| 											<Copy className="h-4 w-4 " /> | ||||
| 										</Button> | ||||
| 									</TooltipTrigger> | ||||
| 									<TooltipContent> | ||||
| 										<p>Click to copy</p> | ||||
| 									</TooltipContent> | ||||
| 								</Tooltip> | ||||
| 							</TooltipProvider> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<DialogFooter className="flex justify-end gap-2"> | ||||
| 						<Button | ||||
| 							type="button" | ||||
| 							variant={'ghost'} | ||||
| 							onClick={() => copyDockerCompose(port.current.value)} | ||||
| 						> | ||||
| 							Copy docker compose | ||||
| 						</Button> | ||||
| 						<Button>Add system</Button> | ||||
| 					</DialogFooter> | ||||
| 				</form> | ||||
| 			</DialogContent> | ||||
| 		</Dialog> | ||||
| 	) | ||||
| } | ||||
| @@ -1,104 +0,0 @@ | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
|  | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| const chartConfig = { | ||||
| 	recv: { | ||||
| 		label: 'Received', | ||||
| 		color: 'hsl(var(--chart-2))', | ||||
| 	}, | ||||
| 	sent: { | ||||
| 		label: 'Sent', | ||||
| 		color: 'hsl(var(--chart-5))', | ||||
| 	}, | ||||
| } satisfies ChartConfig | ||||
|  | ||||
| export default function BandwidthChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: { time: number; sent: number; recv: number }[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				margin={{ | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					top: 10, | ||||
| 					bottom: 0, | ||||
| 				}} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					className="tracking-tighter" | ||||
| 					width={75} | ||||
| 					domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]} | ||||
| 					tickFormatter={(value) => { | ||||
| 						if (value >= 100) { | ||||
| 							return value.toFixed(0) | ||||
| 						} | ||||
| 						return value.toFixed((value * 100) % 1 === 0 ? 1 : 2) | ||||
| 					}} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={' MB/s'} | ||||
| 				/> | ||||
| 				{/* todo: short time if first date is same day, otherwise short date */} | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					content={ | ||||
| 						<ChartTooltipContent | ||||
| 							unit=" MB/s" | ||||
| 							labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 							indicator="line" | ||||
| 						/> | ||||
| 					} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="sent" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-sent)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-sent)" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="recv" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-recv)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-recv)" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,34 +0,0 @@ | ||||
| import { | ||||
| 	Select, | ||||
| 	SelectContent, | ||||
| 	SelectItem, | ||||
| 	SelectTrigger, | ||||
| 	SelectValue, | ||||
| } from '@/components/ui/select' | ||||
| import { $chartTime } from '@/lib/stores' | ||||
| import { chartTimeData, cn } from '@/lib/utils' | ||||
| import { ChartTimes } from '@/types' | ||||
| import { useStore } from '@nanostores/react' | ||||
|  | ||||
| export default function ChartTimeSelect({ className }: { className?: string }) { | ||||
| 	const chartTime = useStore($chartTime) | ||||
|  | ||||
| 	return ( | ||||
| 		<Select | ||||
| 			defaultValue="1h" | ||||
| 			value={chartTime} | ||||
| 			onValueChange={(value: ChartTimes) => $chartTime.set(value)} | ||||
| 		> | ||||
| 			<SelectTrigger className={cn(className, 'px-5')}> | ||||
| 				<SelectValue /> | ||||
| 			</SelectTrigger> | ||||
| 			<SelectContent> | ||||
| 				{Object.entries(chartTimeData).map(([value, { label }]) => ( | ||||
| 					<SelectItem key={label} value={value}> | ||||
| 						{label} | ||||
| 					</SelectItem> | ||||
| 				))} | ||||
| 			</SelectContent> | ||||
| 		</Select> | ||||
| 	) | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { useMemo } from 'react' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| export default function ContainerCpuChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: Record<string, number | string>[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	const chartConfig = useMemo(() => { | ||||
| 		let config = {} as Record< | ||||
| 			string, | ||||
| 			{ | ||||
| 				label: string | ||||
| 				color: string | ||||
| 			} | ||||
| 		> | ||||
| 		const totalUsage = {} as Record<string, number> | ||||
| 		for (let stats of chartData) { | ||||
| 			for (let key in stats) { | ||||
| 				if (key === 'time') { | ||||
| 					continue | ||||
| 				} | ||||
| 				if (!(key in totalUsage)) { | ||||
| 					totalUsage[key] = 0 | ||||
| 				} | ||||
| 				// @ts-ignore | ||||
| 				totalUsage[key] += stats[key] | ||||
| 			} | ||||
| 		} | ||||
| 		let keys = Object.keys(totalUsage) | ||||
| 		keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1)) | ||||
| 		const length = keys.length | ||||
| 		for (let i = 0; i < length; i++) { | ||||
| 			const key = keys[i] | ||||
| 			const hue = ((i * 360) / length) % 360 | ||||
| 			config[key] = { | ||||
| 				label: key, | ||||
| 				color: `hsl(${hue}, 60%, 55%)`, | ||||
| 			} | ||||
| 		} | ||||
| 		return config satisfies ChartConfig | ||||
| 	}, [chartData]) | ||||
|  | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				margin={{ | ||||
| 					top: 10, | ||||
| 				}} | ||||
| 				reverseStackOrder={true} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					// domain={[0, (max: number) => Math.max(Math.ceil(max), 0.4)]} | ||||
| 					width={47} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={'%'} | ||||
| 					tickFormatter={(x) => (x % 1 === 0 ? x : x.toFixed(1))} | ||||
| 				/> | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					// cursor={false} | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 					// @ts-ignore | ||||
| 					itemSorter={(a, b) => b.value - a.value} | ||||
| 					content={<ChartTooltipContent unit="%" indicator="line" />} | ||||
| 				/> | ||||
| 				{Object.keys(chartConfig).map((key) => ( | ||||
| 					<Area | ||||
| 						key={key} | ||||
| 						// isAnimationActive={chartData.length < 20} | ||||
| 						animateNewValues={false} | ||||
| 						animationDuration={1200} | ||||
| 						dataKey={key} | ||||
| 						type="monotoneX" | ||||
| 						fill={chartConfig[key].color} | ||||
| 						fillOpacity={0.4} | ||||
| 						stroke={chartConfig[key].color} | ||||
| 						stackId="a" | ||||
| 					/> | ||||
| 				))} | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,122 +0,0 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { useMemo } from 'react' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| export default function ContainerMemChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: Record<string, number | string>[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	const chartConfig = useMemo(() => { | ||||
| 		let config = {} as Record< | ||||
| 			string, | ||||
| 			{ | ||||
| 				label: string | ||||
| 				color: string | ||||
| 			} | ||||
| 		> | ||||
| 		const totalUsage = {} as Record<string, number> | ||||
| 		for (let stats of chartData) { | ||||
| 			for (let key in stats) { | ||||
| 				if (key === 'time') { | ||||
| 					continue | ||||
| 				} | ||||
| 				if (!(key in totalUsage)) { | ||||
| 					totalUsage[key] = 0 | ||||
| 				} | ||||
| 				// @ts-ignore | ||||
| 				totalUsage[key] += stats[key] | ||||
| 			} | ||||
| 		} | ||||
| 		let keys = Object.keys(totalUsage) | ||||
| 		keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1)) | ||||
| 		const length = keys.length | ||||
| 		for (let i = 0; i < length; i++) { | ||||
| 			const key = keys[i] | ||||
| 			const hue = ((i * 360) / length) % 360 | ||||
| 			config[key] = { | ||||
| 				label: key, | ||||
| 				color: `hsl(${hue}, 60%, 55%)`, | ||||
| 			} | ||||
| 		} | ||||
| 		return config satisfies ChartConfig | ||||
| 	}, [chartData]) | ||||
|  | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				reverseStackOrder={true} | ||||
| 				margin={{ | ||||
| 					top: 10, | ||||
| 				}} | ||||
|  | ||||
| 				// reverseStackOrder={true} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					// domain={[0, (max: number) => Math.ceil(max)]} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={' GB'} | ||||
| 					width={70} | ||||
| 					tickFormatter={(value) => { | ||||
| 						value = value / 1024 | ||||
| 						return value.toFixed((value * 100) % 1 === 0 ? 1 : 2) | ||||
| 					}} | ||||
| 				/> | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					// cursor={false} | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 					// @ts-ignore | ||||
| 					itemSorter={(a, b) => b.value - a.value} | ||||
| 					content={<ChartTooltipContent unit=" MiB" indicator="line" />} | ||||
| 				/> | ||||
| 				{Object.keys(chartConfig).map((key) => ( | ||||
| 					<Area | ||||
| 						key={key} | ||||
| 						isAnimationActive={chartData.length < 20} | ||||
| 						animateNewValues={false} | ||||
| 						animationDuration={1200} | ||||
| 						dataKey={key} | ||||
| 						type="monotoneX" | ||||
| 						fill={chartConfig[key].color} | ||||
| 						fillOpacity={0.4} | ||||
| 						stroke={chartConfig[key].color} | ||||
| 						stackId="a" | ||||
| 					/> | ||||
| 				))} | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,81 +0,0 @@ | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
|  | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { chartTimeData, formatShortDate } from '@/lib/utils' | ||||
| import Spinner from '../spinner' | ||||
| import { useStore } from '@nanostores/react' | ||||
| import { $chartTime } from '@/lib/stores' | ||||
|  | ||||
| const chartConfig = { | ||||
| 	cpu: { | ||||
| 		label: 'CPU Usage', | ||||
| 		color: 'hsl(var(--chart-1))', | ||||
| 	}, | ||||
| } satisfies ChartConfig | ||||
|  | ||||
| export default function CpuChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: { time: number; cpu: number }[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	const chartTime = useStore($chartTime) | ||||
|  | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart accessibilityLayer data={chartData} margin={{ top: 10 }}> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					// domain={[0, (max: number) => Math.ceil(max)]} | ||||
| 					width={48} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={'%'} | ||||
| 				/> | ||||
| 				{/* todo: short time if first date is same day, otherwise short date */} | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					minTickGap={35} | ||||
| 					tickMargin={8} | ||||
| 					axisLine={false} | ||||
| 					tickFormatter={chartTimeData[chartTime].format} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					content={ | ||||
| 						<ChartTooltipContent | ||||
| 							unit="%" | ||||
| 							labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 							indicator="line" | ||||
| 						/> | ||||
| 					} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="cpu" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-cpu)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-cpu)" | ||||
| 					animationDuration={1200} | ||||
| 					// animationEasing="ease-out" | ||||
| 					// animateNewValues={false} | ||||
| 				/> | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,101 +0,0 @@ | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
|  | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import { useMemo } from 'react' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| const chartConfig = { | ||||
| 	diskUsed: { | ||||
| 		label: 'Disk Usage', | ||||
| 		color: 'hsl(var(--chart-4))', | ||||
| 	}, | ||||
| } satisfies ChartConfig | ||||
|  | ||||
| export default function DiskChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: { time: number; disk: number; diskUsed: number }[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	const diskSize = useMemo(() => { | ||||
| 		return Math.round(chartData[0]?.disk) | ||||
| 	}, [chartData]) | ||||
|  | ||||
| 	// const ticks = useMemo(() => { | ||||
| 	// 	let ticks = [0] | ||||
| 	// 	for (let i = 1; i < diskSize; i += diskSize / 5) { | ||||
| 	// 		ticks.push(Math.trunc(i)) | ||||
| 	// 	} | ||||
| 	// 	ticks.push(diskSize) | ||||
| 	// 	return ticks | ||||
| 	// }, [diskSize]) | ||||
|  | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				margin={{ | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					top: 10, | ||||
| 					bottom: 0, | ||||
| 				}} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					className="tracking-tighter" | ||||
| 					width={diskSize >= 1000 ? 75 : 65} | ||||
| 					domain={[0, diskSize]} | ||||
| 					tickCount={9} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={' GB'} | ||||
| 				/> | ||||
| 				{/* todo: short time if first date is same day, otherwise short date */} | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					content={ | ||||
| 						<ChartTooltipContent | ||||
| 							unit=" GB" | ||||
| 							labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 							indicator="line" | ||||
| 						/> | ||||
| 					} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="diskUsed" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-diskUsed)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-diskUsed)" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,104 +0,0 @@ | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
|  | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| const chartConfig = { | ||||
| 	read: { | ||||
| 		label: 'Read', | ||||
| 		color: 'hsl(var(--chart-1))', | ||||
| 	}, | ||||
| 	write: { | ||||
| 		label: 'Write', | ||||
| 		color: 'hsl(var(--chart-3))', | ||||
| 	}, | ||||
| } satisfies ChartConfig | ||||
|  | ||||
| export default function DiskIoChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: { time: number; read: number; write: number }[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				margin={{ | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					top: 10, | ||||
| 					bottom: 0, | ||||
| 				}} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					className="tracking-tighter" | ||||
| 					width={75} | ||||
| 					domain={[0, (max: number) => (max <= 0.4 ? 0.4 : Math.ceil(max))]} | ||||
| 					tickFormatter={(value) => { | ||||
| 						if (value >= 100) { | ||||
| 							return value.toFixed(0) | ||||
| 						} | ||||
| 						return value.toFixed((value * 100) % 1 === 0 ? 1 : 2) | ||||
| 					}} | ||||
| 					tickLine={false} | ||||
| 					axisLine={false} | ||||
| 					unit={' MB/s'} | ||||
| 				/> | ||||
| 				{/* todo: short time if first date is same day, otherwise short date */} | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					content={ | ||||
| 						<ChartTooltipContent | ||||
| 							unit=" MB/s" | ||||
| 							labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 							indicator="line" | ||||
| 						/> | ||||
| 					} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="write" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-write)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-write)" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="read" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-read)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-read)" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,111 +0,0 @@ | ||||
| import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' | ||||
|  | ||||
| import { | ||||
| 	ChartConfig, | ||||
| 	ChartContainer, | ||||
| 	ChartTooltip, | ||||
| 	ChartTooltipContent, | ||||
| } from '@/components/ui/chart' | ||||
| import { formatShortDate, hourWithMinutes } from '@/lib/utils' | ||||
| import { useMemo } from 'react' | ||||
| import Spinner from '../spinner' | ||||
|  | ||||
| export default function MemChart({ | ||||
| 	chartData, | ||||
| 	ticks, | ||||
| }: { | ||||
| 	chartData: { time: number; mem: number; memUsed: number; memCache: number }[] | ||||
| 	ticks: number[] | ||||
| }) { | ||||
| 	const totalMem = useMemo(() => { | ||||
| 		const maxMem = Math.ceil(chartData[0]?.mem) | ||||
| 		return maxMem > 2 && maxMem % 2 !== 0 ? maxMem + 1 : maxMem | ||||
| 	}, [chartData]) | ||||
|  | ||||
| 	const chartConfig = useMemo( | ||||
| 		() => ({ | ||||
| 			memCache: { | ||||
| 				label: 'Cache / Buffers', | ||||
| 				color: 'hsl(var(--chart-2))', | ||||
| 			}, | ||||
| 			memUsed: { | ||||
| 				label: 'Used', | ||||
| 				color: 'hsl(var(--chart-2))', | ||||
| 			}, | ||||
| 		}), | ||||
| 		[] | ||||
| 	) satisfies ChartConfig | ||||
|  | ||||
| 	if (!chartData.length || !ticks.length) { | ||||
| 		return <Spinner /> | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<ChartContainer config={chartConfig} className="h-full w-full absolute aspect-auto"> | ||||
| 			<AreaChart | ||||
| 				accessibilityLayer | ||||
| 				data={chartData} | ||||
| 				margin={{ | ||||
| 					top: 10, | ||||
| 				}} | ||||
| 			> | ||||
| 				<CartesianGrid vertical={false} /> | ||||
| 				<YAxis | ||||
| 					// use "ticks" instead of domain / tickcount if need more control | ||||
| 					domain={[0, totalMem]} | ||||
| 					tickLine={false} | ||||
| 					width={totalMem >= 100 ? 65 : 58} | ||||
| 					// allowDecimals={false} | ||||
| 					axisLine={false} | ||||
| 					unit={' GB'} | ||||
| 				/> | ||||
| 				{/* todo: short time if first date is same day, otherwise short date */} | ||||
| 				<XAxis | ||||
| 					dataKey="time" | ||||
| 					domain={[ticks[0], ticks.at(-1)!]} | ||||
| 					ticks={ticks} | ||||
| 					type="number" | ||||
| 					scale={'time'} | ||||
| 					tickLine={true} | ||||
| 					axisLine={false} | ||||
| 					tickMargin={8} | ||||
| 					minTickGap={30} | ||||
| 					tickFormatter={hourWithMinutes} | ||||
| 				/> | ||||
| 				<ChartTooltip | ||||
| 					// cursor={false} | ||||
| 					animationEasing="ease-out" | ||||
| 					animationDuration={150} | ||||
| 					content={ | ||||
| 						<ChartTooltipContent | ||||
| 							unit=" GB" | ||||
| 							// @ts-ignore | ||||
| 							itemSorter={(a, b) => a.name.localeCompare(b.name)} | ||||
| 							labelFormatter={(_, data) => formatShortDate(data[0].payload.time)} | ||||
| 							indicator="line" | ||||
| 						/> | ||||
| 					} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="memUsed" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-memUsed)" | ||||
| 					fillOpacity={0.4} | ||||
| 					stroke="var(--color-memUsed)" | ||||
| 					stackId="a" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 				<Area | ||||
| 					dataKey="memCache" | ||||
| 					type="monotoneX" | ||||
| 					fill="var(--color-memCache)" | ||||
| 					fillOpacity={0.2} | ||||
| 					strokeOpacity={0.3} | ||||
| 					stroke="var(--color-memCache)" | ||||
| 					stackId="a" | ||||
| 					animationDuration={1200} | ||||
| 				/> | ||||
| 			</AreaChart> | ||||
| 		</ChartContainer> | ||||
| 	) | ||||
| } | ||||
| @@ -1,155 +0,0 @@ | ||||
| import { | ||||
| 	DatabaseBackupIcon, | ||||
| 	Github, | ||||
| 	LayoutDashboard, | ||||
| 	LockKeyholeIcon, | ||||
| 	LogsIcon, | ||||
| 	MailIcon, | ||||
| 	Server, | ||||
| 	UsersIcon, | ||||
| } from 'lucide-react' | ||||
|  | ||||
| import { | ||||
| 	CommandDialog, | ||||
| 	CommandEmpty, | ||||
| 	CommandGroup, | ||||
| 	CommandInput, | ||||
| 	CommandItem, | ||||
| 	CommandList, | ||||
| 	CommandSeparator, | ||||
| 	CommandShortcut, | ||||
| } from '@/components/ui/command' | ||||
| import { useEffect, useState } from 'react' | ||||
| import { useStore } from '@nanostores/react' | ||||
| import { $systems } from '@/lib/stores' | ||||
| import { isAdmin } from '@/lib/utils' | ||||
| import { navigate } from './router' | ||||
|  | ||||
| export default function CommandPalette() { | ||||
| 	const [open, setOpen] = useState(false) | ||||
| 	const systems = useStore($systems) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const down = (e: KeyboardEvent) => { | ||||
| 			if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { | ||||
| 				e.preventDefault() | ||||
| 				setOpen((open) => !open) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		document.addEventListener('keydown', down) | ||||
| 		return () => document.removeEventListener('keydown', down) | ||||
| 	}, []) | ||||
|  | ||||
| 	return ( | ||||
| 		<CommandDialog open={open} onOpenChange={setOpen}> | ||||
| 			<CommandInput placeholder="Type a command or search..." /> | ||||
| 			<CommandList> | ||||
| 				<CommandEmpty>No results found.</CommandEmpty> | ||||
| 				<CommandGroup heading="Suggestions"> | ||||
| 					<CommandItem | ||||
| 						keywords={['home']} | ||||
| 						onSelect={() => { | ||||
| 							navigate('/') | ||||
| 							setOpen((open) => !open) | ||||
| 						}} | ||||
| 					> | ||||
| 						<LayoutDashboard className="mr-2 h-4 w-4" /> | ||||
| 						<span>Dashboard</span> | ||||
| 						<CommandShortcut>Page</CommandShortcut> | ||||
| 					</CommandItem> | ||||
| 					<CommandItem | ||||
| 						keywords={['github']} | ||||
| 						onSelect={() => { | ||||
| 							window.location.href = 'https://github.com/henrygd/beszel/blob/main/readme.md' | ||||
| 						}} | ||||
| 					> | ||||
| 						<Github className="mr-2 h-4 w-4" /> | ||||
| 						<span>Documentation</span> | ||||
| 						<CommandShortcut>GitHub</CommandShortcut> | ||||
| 					</CommandItem> | ||||
| 				</CommandGroup> | ||||
| 				{systems.length > 0 && ( | ||||
| 					<> | ||||
| 						<CommandSeparator /> | ||||
| 						<CommandGroup heading="Systems"> | ||||
| 							{systems.map((system) => ( | ||||
| 								<CommandItem | ||||
| 									key={system.id} | ||||
| 									onSelect={() => { | ||||
| 										navigate(`/system/${system.name}`) | ||||
| 										setOpen(false) | ||||
| 									}} | ||||
| 								> | ||||
| 									<Server className="mr-2 h-4 w-4" /> | ||||
| 									<span>{system.name}</span> | ||||
| 									<CommandShortcut>{system.host}</CommandShortcut> | ||||
| 								</CommandItem> | ||||
| 							))} | ||||
| 						</CommandGroup> | ||||
| 					</> | ||||
| 				)} | ||||
| 				{isAdmin() && ( | ||||
| 					<> | ||||
| 						<CommandSeparator /> | ||||
| 						<CommandGroup heading="Admin"> | ||||
| 							<CommandItem | ||||
| 								keywords={['pocketbase']} | ||||
| 								onSelect={() => { | ||||
| 									setOpen(false) | ||||
| 									window.open('/_/', '_blank') | ||||
| 								}} | ||||
| 							> | ||||
| 								<UsersIcon className="mr-2 h-4 w-4" /> | ||||
| 								<span>Users</span> | ||||
| 								<CommandShortcut>Admin</CommandShortcut> | ||||
| 							</CommandItem> | ||||
| 							<CommandItem | ||||
| 								onSelect={() => { | ||||
| 									setOpen(false) | ||||
| 									window.open('/_/#/logs', '_blank') | ||||
| 								}} | ||||
| 							> | ||||
| 								<LogsIcon className="mr-2 h-4 w-4" /> | ||||
| 								<span>Logs</span> | ||||
| 								<CommandShortcut>Admin</CommandShortcut> | ||||
| 							</CommandItem> | ||||
| 							<CommandItem | ||||
| 								onSelect={() => { | ||||
| 									setOpen(false) | ||||
| 									window.open('/_/#/settings/backups', '_blank') | ||||
| 								}} | ||||
| 							> | ||||
| 								<DatabaseBackupIcon className="mr-2 h-4 w-4" /> | ||||
| 								<span>Database backups</span> | ||||
| 								<CommandShortcut>Admin</CommandShortcut> | ||||
| 							</CommandItem> | ||||
| 							<CommandItem | ||||
| 								keywords={['oauth', 'oicd']} | ||||
| 								onSelect={() => { | ||||
| 									setOpen(false) | ||||
| 									window.open('/_/#/settings/auth-providers', '_blank') | ||||
| 								}} | ||||
| 							> | ||||
| 								<LockKeyholeIcon className="mr-2 h-4 w-4" /> | ||||
| 								<span>Auth Providers</span> | ||||
| 								<CommandShortcut>Admin</CommandShortcut> | ||||
| 							</CommandItem> | ||||
| 							<CommandItem | ||||
| 								keywords={['email']} | ||||
| 								onSelect={() => { | ||||
| 									setOpen(false) | ||||
| 									window.open('/_/#/settings/mail', '_blank') | ||||
| 								}} | ||||
| 							> | ||||
| 								<MailIcon className="mr-2 h-4 w-4" /> | ||||
| 								<span>SMTP settings</span> | ||||
| 								<CommandShortcut>Admin</CommandShortcut> | ||||
| 							</CommandItem> | ||||
| 						</CommandGroup> | ||||
| 					</> | ||||
| 				)} | ||||
| 			</CommandList> | ||||
| 		</CommandDialog> | ||||
| 	) | ||||
| } | ||||
| @@ -1,319 +0,0 @@ | ||||
| import { cn } from '@/lib/utils' | ||||
| import { buttonVariants } from '@/components/ui/button' | ||||
| import { Input } from '@/components/ui/input' | ||||
| import { Label } from '@/components/ui/label' | ||||
| import { LoaderCircle, LockIcon, LogInIcon, MailIcon, UserIcon } from 'lucide-react' | ||||
| import { $authenticated, pb } from '@/lib/stores' | ||||
| import * as v from 'valibot' | ||||
| import { toast } from '../ui/use-toast' | ||||
| import { | ||||
| 	Dialog, | ||||
| 	DialogContent, | ||||
| 	DialogTrigger, | ||||
| 	DialogHeader, | ||||
| 	DialogTitle, | ||||
| } from '@/components/ui/dialog' | ||||
| import { useState } from 'react' | ||||
| import { AuthMethodsList } from 'pocketbase' | ||||
| import { Link } from '../router' | ||||
|  | ||||
| const honeypot = v.literal('') | ||||
| const emailSchema = v.pipe(v.string(), v.email('Invalid email address.')) | ||||
| const passwordSchema = v.pipe( | ||||
| 	v.string(), | ||||
| 	v.minLength(10, 'Password must be at least 10 characters.') | ||||
| ) | ||||
|  | ||||
| const LoginSchema = v.looseObject({ | ||||
| 	name: honeypot, | ||||
| 	email: emailSchema, | ||||
| 	password: passwordSchema, | ||||
| }) | ||||
|  | ||||
| const RegisterSchema = v.looseObject({ | ||||
| 	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, | ||||
| 	password: passwordSchema, | ||||
| 	passwordConfirm: passwordSchema, | ||||
| }) | ||||
|  | ||||
| const showLoginFaliedToast = () => { | ||||
| 	toast({ | ||||
| 		title: 'Login attempt failed', | ||||
| 		description: 'Please check your credentials and try again', | ||||
| 		variant: 'destructive', | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| export function UserAuthForm({ | ||||
| 	className, | ||||
| 	isFirstRun, | ||||
| 	authMethods, | ||||
| 	...props | ||||
| }: { | ||||
| 	className?: string | ||||
| 	isFirstRun: boolean | ||||
| 	authMethods: AuthMethodsList | ||||
| }) { | ||||
| 	const [isLoading, setIsLoading] = useState<boolean>(false) | ||||
| 	const [isGitHubLoading, setIsOauthLoading] = useState<boolean>(false) | ||||
| 	const [errors, setErrors] = useState<Record<string, string | undefined>>({}) | ||||
|  | ||||
| 	async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { | ||||
| 		e.preventDefault() | ||||
| 		setIsLoading(true) | ||||
| 		try { | ||||
| 			const formData = new FormData(e.target as HTMLFormElement) | ||||
| 			const data = Object.fromEntries(formData) as Record<string, any> | ||||
| 			const Schema = isFirstRun ? RegisterSchema : LoginSchema | ||||
| 			const result = v.safeParse(Schema, data) | ||||
| 			if (!result.success) { | ||||
| 				console.log(result) | ||||
| 				let errors = {} | ||||
| 				for (const issue of result.issues) { | ||||
| 					// @ts-ignore | ||||
| 					errors[issue.path[0].key] = issue.message | ||||
| 				} | ||||
| 				setErrors(errors) | ||||
| 				return | ||||
| 			} | ||||
| 			const { email, password, passwordConfirm, username } = result.output | ||||
| 			if (isFirstRun) { | ||||
| 				// check that passwords match | ||||
| 				if (password !== passwordConfirm) { | ||||
| 					let msg = 'Passwords do not match' | ||||
| 					setErrors({ passwordConfirm: msg }) | ||||
| 					return | ||||
| 				} | ||||
| 				await pb.admins.create({ | ||||
| 					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) | ||||
| 			} else { | ||||
| 				await pb.collection('users').authWithPassword(email, password) | ||||
| 			} | ||||
| 			$authenticated.set(true) | ||||
| 		} catch (e) { | ||||
| 			showLoginFaliedToast() | ||||
| 		} finally { | ||||
| 			setIsLoading(false) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if (!authMethods) { | ||||
| 		return null | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className={cn('grid gap-6', className)} {...props}> | ||||
| 			{authMethods.emailPassword && ( | ||||
| 				<> | ||||
| 					<form onSubmit={handleSubmit} onChange={() => setErrors({})}> | ||||
| 						<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"> | ||||
| 										Username | ||||
| 									</Label> | ||||
| 									<Input | ||||
| 										autoFocus={true} | ||||
| 										id="username" | ||||
| 										name="username" | ||||
| 										required | ||||
| 										placeholder="username" | ||||
| 										type="username" | ||||
| 										autoCapitalize="none" | ||||
| 										autoComplete="username" | ||||
| 										autoCorrect="off" | ||||
| 										disabled={isLoading || isGitHubLoading} | ||||
| 										className="pl-9" | ||||
| 									/> | ||||
| 									{errors?.username && ( | ||||
| 										<p className="px-1 text-xs text-red-600">{errors.username}</p> | ||||
| 									)} | ||||
| 								</div> | ||||
| 							)} | ||||
| 							<div className="grid gap-1 relative"> | ||||
| 								<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> | ||||
| 								<Label className="sr-only" htmlFor="email"> | ||||
| 									Email | ||||
| 								</Label> | ||||
| 								<Input | ||||
| 									id="email" | ||||
| 									name="email" | ||||
| 									required | ||||
| 									placeholder={isFirstRun ? 'email' : 'name@example.com'} | ||||
| 									type="email" | ||||
| 									autoCapitalize="none" | ||||
| 									autoComplete="email" | ||||
| 									autoCorrect="off" | ||||
| 									disabled={isLoading || isGitHubLoading} | ||||
| 									className="pl-9" | ||||
| 								/> | ||||
| 								{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>} | ||||
| 							</div> | ||||
| 							<div className="grid gap-1 relative"> | ||||
| 								<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> | ||||
| 								<Label className="sr-only" htmlFor="pass"> | ||||
| 									Password | ||||
| 								</Label> | ||||
| 								<Input | ||||
| 									id="pass" | ||||
| 									name="password" | ||||
| 									placeholder="password" | ||||
| 									required | ||||
| 									type="password" | ||||
| 									autoComplete="current-password" | ||||
| 									disabled={isLoading || isGitHubLoading} | ||||
| 									className="pl-9" | ||||
| 								/> | ||||
| 								{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>} | ||||
| 							</div> | ||||
| 							{isFirstRun && ( | ||||
| 								<div className="grid gap-1 relative"> | ||||
| 									<LockIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> | ||||
| 									<Label className="sr-only" htmlFor="pass2"> | ||||
| 										Confirm password | ||||
| 									</Label> | ||||
| 									<Input | ||||
| 										id="pass2" | ||||
| 										name="passwordConfirm" | ||||
| 										placeholder="confirm password" | ||||
| 										required | ||||
| 										type="password" | ||||
| 										autoComplete="current-password" | ||||
| 										disabled={isLoading || isGitHubLoading} | ||||
| 										className="pl-9" | ||||
| 									/> | ||||
| 									{errors?.passwordConfirm && ( | ||||
| 										<p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p> | ||||
| 									)} | ||||
| 								</div> | ||||
| 							)} | ||||
| 							<div className="sr-only"> | ||||
| 								{/* honeypot */} | ||||
| 								<label htmlFor="name"></label> | ||||
| 								<input id="name" type="text" name="name" tabIndex={-1} /> | ||||
| 							</div> | ||||
| 							<button className={cn(buttonVariants())} disabled={isLoading}> | ||||
| 								{isLoading ? ( | ||||
| 									<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> | ||||
| 								) : ( | ||||
| 									<LogInIcon className="mr-2 h-4 w-4" /> | ||||
| 								)} | ||||
| 								{isFirstRun ? 'Create account' : 'Sign in'} | ||||
| 							</button> | ||||
| 						</div> | ||||
| 					</form> | ||||
| 					<div className="relative"> | ||||
| 						<div className="absolute inset-0 flex items-center"> | ||||
| 							<span className="w-full border-t" /> | ||||
| 						</div> | ||||
| 						<div className="relative flex justify-center text-xs uppercase"> | ||||
| 							<span className="bg-background px-2 text-muted-foreground">Or continue with</span> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</> | ||||
| 			)} | ||||
|  | ||||
| 			{authMethods.authProviders.length > 0 && ( | ||||
| 				<div className="grid gap-2 -mt-1"> | ||||
| 					{authMethods.authProviders.map((provider) => ( | ||||
| 						<button | ||||
| 							key={provider.name} | ||||
| 							type="button" | ||||
| 							className={cn(buttonVariants({ variant: 'outline' }), { | ||||
| 								'justify-self-center': !authMethods.emailPassword, | ||||
| 								'px-5': !authMethods.emailPassword, | ||||
| 							})} | ||||
| 							onClick={async () => { | ||||
| 								setIsOauthLoading(true) | ||||
| 								try { | ||||
| 									await pb.collection('users').authWithOAuth2({ provider: provider.name }) | ||||
| 									$authenticated.set(pb.authStore.isValid) | ||||
| 								} catch (e) { | ||||
| 									showLoginFaliedToast() | ||||
| 								} finally { | ||||
| 									setIsOauthLoading(false) | ||||
| 								} | ||||
| 							}} | ||||
| 							disabled={isLoading || isGitHubLoading} | ||||
| 						> | ||||
| 							{isGitHubLoading ? ( | ||||
| 								<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> | ||||
| 							) : ( | ||||
| 								<img | ||||
| 									className="mr-2 h-4 w-4 dark:invert" | ||||
| 									src={`/static/${provider.name}.svg`} | ||||
| 									alt="" | ||||
| 									onError={(e) => { | ||||
| 										e.currentTarget.src = '/static/lock.svg' | ||||
| 									}} | ||||
| 								/> | ||||
| 							)} | ||||
| 							<span className="translate-y-[1px]">{provider.displayName}</span> | ||||
| 						</button> | ||||
| 					))} | ||||
| 				</div> | ||||
| 			)} | ||||
|  | ||||
| 			{!authMethods.authProviders.length && ( | ||||
| 				<Dialog> | ||||
| 					<DialogTrigger asChild> | ||||
| 						<button type="button" className={cn(buttonVariants({ variant: 'outline' }))}> | ||||
| 							<img className="mr-2 h-4 w-4 dark:invert" src="/static/github.svg" alt="" /> | ||||
| 							<span className="translate-y-[1px]">GitHub</span> | ||||
| 						</button> | ||||
| 					</DialogTrigger> | ||||
| 					<DialogContent style={{ maxWidth: 440, width: '90%' }}> | ||||
| 						<DialogHeader> | ||||
| 							<DialogTitle>OAuth 2 / OIDC support</DialogTitle> | ||||
| 						</DialogHeader> | ||||
| 						<div className="text-primary/70 text-[0.95em] contents"> | ||||
| 							<p>Beszel supports OpenID Connect and many OAuth2 authentication providers.</p> | ||||
| 							<p> | ||||
| 								Please view the{' '} | ||||
| 								<a | ||||
| 									href="https://github.com/henrygd/beszel/blob/main/readme.md#oauth--oidc-integration" | ||||
| 									className={cn(buttonVariants({ variant: 'link' }), 'p-0 h-auto')} | ||||
| 								> | ||||
| 									GitHub README | ||||
| 								</a>{' '} | ||||
| 								for instructions. | ||||
| 							</p> | ||||
| 						</div> | ||||
| 					</DialogContent> | ||||
| 				</Dialog> | ||||
| 			)} | ||||
|  | ||||
| 			{authMethods.emailPassword && !isFirstRun && ( | ||||
| 				<Link | ||||
| 					href="/forgot-password" | ||||
| 					className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity" | ||||
| 				> | ||||
| 					Forgot password? | ||||
| 				</Link> | ||||
| 			)} | ||||
| 		</div> | ||||
| 	) | ||||
| } | ||||
| @@ -1,103 +0,0 @@ | ||||
| import { LoaderCircle, MailIcon, SendHorizonalIcon } from 'lucide-react' | ||||
| import { Input } from '../ui/input' | ||||
| import { Label } from '../ui/label' | ||||
| import { useCallback, useState } from 'react' | ||||
| import { toast } from '../ui/use-toast' | ||||
| import { buttonVariants } from '../ui/button' | ||||
| import { cn } from '@/lib/utils' | ||||
| import { pb } from '@/lib/stores' | ||||
| import { Dialog, DialogHeader } from '../ui/dialog' | ||||
| import { DialogContent, DialogTrigger, DialogTitle } from '../ui/dialog' | ||||
|  | ||||
| const showLoginFaliedToast = () => { | ||||
| 	toast({ | ||||
| 		title: 'Login attempt failed', | ||||
| 		description: 'Please check your credentials and try again', | ||||
| 		variant: 'destructive', | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| export default function ForgotPassword() { | ||||
| 	const [isLoading, setIsLoading] = useState<boolean>(false) | ||||
| 	const [email, setEmail] = useState('') | ||||
|  | ||||
| 	const handleSubmit = useCallback( | ||||
| 		async (e: React.FormEvent<HTMLFormElement>) => { | ||||
| 			e.preventDefault() | ||||
| 			setIsLoading(true) | ||||
| 			try { | ||||
| 				// console.log(email) | ||||
| 				await pb.collection('users').requestPasswordReset(email) | ||||
| 				toast({ | ||||
| 					title: 'Password reset request received', | ||||
| 					description: `Check ${email} for a reset link.`, | ||||
| 				}) | ||||
| 			} catch (e) { | ||||
| 				showLoginFaliedToast() | ||||
| 			} finally { | ||||
| 				setIsLoading(false) | ||||
| 				setEmail('') | ||||
| 			} | ||||
| 		}, | ||||
| 		[email] | ||||
| 	) | ||||
|  | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<form onSubmit={handleSubmit}> | ||||
| 				<div className="grid gap-3"> | ||||
| 					<div className="grid gap-1 relative"> | ||||
| 						<MailIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> | ||||
| 						<Label className="sr-only" htmlFor="email"> | ||||
| 							Email | ||||
| 						</Label> | ||||
| 						<Input | ||||
| 							value={email} | ||||
| 							onChange={(e) => setEmail(e.target.value)} | ||||
| 							id="email" | ||||
| 							name="email" | ||||
| 							required | ||||
| 							placeholder="name@example.com" | ||||
| 							type="email" | ||||
| 							autoCapitalize="none" | ||||
| 							autoComplete="email" | ||||
| 							autoCorrect="off" | ||||
| 							disabled={isLoading} | ||||
| 							className="pl-9" | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<button className={cn(buttonVariants())} disabled={isLoading}> | ||||
| 						{isLoading ? ( | ||||
| 							<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> | ||||
| 						) : ( | ||||
| 							<SendHorizonalIcon className="mr-2 h-4 w-4" /> | ||||
| 						)} | ||||
| 						Reset password | ||||
| 					</button> | ||||
| 				</div> | ||||
| 			</form> | ||||
| 			<Dialog> | ||||
| 				<DialogTrigger asChild> | ||||
| 					<button className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"> | ||||
| 						Command line instructions | ||||
| 					</button> | ||||
| 				</DialogTrigger> | ||||
| 				<DialogContent className="max-w-[33em]"> | ||||
| 					<DialogHeader> | ||||
| 						<DialogTitle>Command line instructions</DialogTitle> | ||||
| 					</DialogHeader> | ||||
| 					<p className="text-primary/70 text-[0.95em] leading-relaxed"> | ||||
| 						If you've lost the password to your admin account, you may reset it using the following | ||||
| 						command. | ||||
| 					</p> | ||||
| 					<p className="text-primary/70 text-[0.95em] leading-relaxed"> | ||||
| 						Then log into the backend and reset your user account password in the users table. | ||||
| 					</p> | ||||
| 					<code className="bg-muted rounded-sm py-0.5 px-2.5 mr-auto text-sm"> | ||||
| 						beszel admin update youremail@example.com newpassword | ||||
| 					</code> | ||||
| 				</DialogContent> | ||||
| 			</Dialog> | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
| @@ -1,63 +0,0 @@ | ||||
| import { UserAuthForm } from '@/components/login/auth-form' | ||||
| import { Logo } from '../logo' | ||||
| import { useEffect, useMemo, useState } from 'react' | ||||
| import { pb } from '@/lib/stores' | ||||
| import { useStore } from '@nanostores/react' | ||||
| import ForgotPassword from './forgot-pass-form' | ||||
| import { $router } from '../router' | ||||
| import { AuthMethodsList } from 'pocketbase' | ||||
|  | ||||
| export default function () { | ||||
| 	const page = useStore($router) | ||||
| 	const [isFirstRun, setFirstRun] = useState(false) | ||||
| 	const [authMethods, setAuthMethods] = useState<AuthMethodsList>() | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		document.title = 'Login / Beszel' | ||||
|  | ||||
| 		pb.send('/api/beszel/first-run', {}).then(({ firstRun }) => { | ||||
| 			setFirstRun(firstRun) | ||||
| 		}) | ||||
| 	}, []) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		pb.collection('users') | ||||
| 			.listAuthMethods() | ||||
| 			.then((methods) => { | ||||
| 				setAuthMethods(methods) | ||||
| 			}) | ||||
| 	}, []) | ||||
|  | ||||
| 	const subtitle = useMemo(() => { | ||||
| 		if (isFirstRun) { | ||||
| 			return 'Please create an admin account' | ||||
| 		} else if (page?.path === '/forgot-password') { | ||||
| 			return 'Enter email address to reset password' | ||||
| 		} else { | ||||
| 			return 'Please sign in to your account' | ||||
| 		} | ||||
| 	}, [isFirstRun, page]) | ||||
|  | ||||
| 	if (!authMethods) { | ||||
| 		return null | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="min-h-screen grid items-center py-12"> | ||||
| 			<div className="grid gap-5 w-full px-4 mx-auto" style={{ maxWidth: '22em' }}> | ||||
| 				<div className="text-center"> | ||||
| 					<h1 className="mb-3"> | ||||
| 						<Logo className="h-7 fill-foreground mx-auto" /> | ||||
| 						<span className="sr-only">Beszel</span> | ||||
| 					</h1> | ||||
| 					<p className="text-sm text-muted-foreground">{subtitle}</p> | ||||
| 				</div> | ||||
| 				{page?.path === '/forgot-password' ? ( | ||||
| 					<ForgotPassword /> | ||||
| 				) : ( | ||||
| 					<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} /> | ||||
| 				)} | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	) | ||||
| } | ||||