mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-07 06:40:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f043f2890c | ||
|
|
057108a2f3 | ||
|
|
5879d4b3da | ||
|
|
16769d5687 | ||
|
|
5ea32c48b6 | ||
|
|
a06d922d78 | ||
|
|
0ed14fc73d | ||
|
|
61bd0c8bdd | ||
|
|
e5d67686db | ||
|
|
96462528c8 | ||
|
|
d92f60f757 | ||
|
|
cba8153d4a | ||
|
|
08067bc8f8 | ||
|
|
a714637fee | ||
|
|
956b9334d0 | ||
|
|
996dd90386 | ||
|
|
28abafb5b2 | ||
|
|
5488189e5f | ||
|
|
d9713aa696 | ||
|
|
eab03a1da5 | ||
|
|
1f139ba3bf | ||
|
|
4040a5d9c7 | ||
|
|
01c4816d1c | ||
|
|
c27c21d5c9 | ||
|
|
f967ba07f9 | ||
|
|
91d5f92c55 | ||
|
|
965afb3f77 | ||
|
|
68bf4ed148 | ||
|
|
8985adcf67 | ||
|
|
91cbeb3f82 | ||
|
|
91ae9eda1a | ||
|
|
54851bd1cf | ||
|
|
3c5838283e | ||
|
|
df6ffba18c | ||
|
|
4077201f09 | ||
|
|
05a91adc53 | ||
|
|
1c75cb8be0 | ||
|
|
d69effee7a | ||
|
|
ebe5278183 | ||
|
|
16d6f2670d | ||
|
|
abc53b3cc0 | ||
|
|
a1ae82169c | ||
|
|
8c849aa423 | ||
|
|
0d699951b7 | ||
|
|
1ffe413eb7 | ||
|
|
c3c3ed8ecf | ||
|
|
0103b99651 | ||
|
|
b8c100823f | ||
|
|
c7819cd96e | ||
|
|
17aa37c64f | ||
|
|
045f24ef85 | ||
|
|
6ab9c6724e | ||
|
|
6efc72ce31 | ||
|
|
1f6bd1391a | ||
|
|
5b93181d94 | ||
|
|
f07bd749c4 | ||
|
|
887381ad4f | ||
|
|
11617b46b3 | ||
|
|
5a8488926c | ||
|
|
db2ed8e424 | ||
|
|
312834f921 | ||
|
|
9d149ab660 | ||
|
|
9d7e24ab78 | ||
|
|
99c684e75e | ||
|
|
a402cc6189 | ||
|
|
0bac110d8a | ||
|
|
7a955467fd | ||
|
|
eb7ecc6fe4 | ||
|
|
f59e0e813c | ||
|
|
b4d3977871 | ||
|
|
b55436842f | ||
|
|
cdfd5bd13f | ||
|
|
5a66f28e81 | ||
|
|
be3daa42fc | ||
|
|
42b290f169 | ||
|
|
99fda6b023 | ||
|
|
7f128509e9 | ||
|
|
fee8c860d7 | ||
|
|
3c278230f4 | ||
|
|
d853a83ec6 | ||
|
|
a78ef878eb | ||
|
|
312e320c8f | ||
|
|
73c3c436a4 | ||
|
|
f42cfe7ddc | ||
|
|
0f6f2f4294 | ||
|
|
fc3febc3be | ||
|
|
5b90804a13 | ||
|
|
43547b947f | ||
|
|
e2a8284c03 | ||
|
|
c1178ebfe1 | ||
|
|
7baf2edf26 | ||
|
|
66b7691e01 | ||
|
|
7f368817e0 | ||
|
|
5fa59ab7de | ||
|
|
0c80f0f23a | ||
|
|
1eb3549ed9 | ||
|
|
5c73eba475 | ||
|
|
76794305c8 | ||
|
|
724dd3b666 | ||
|
|
c828653d20 | ||
|
|
ff358edd8b | ||
|
|
3b48339209 | ||
|
|
42ef993cd6 | ||
|
|
39c5f7bb69 | ||
|
|
9831df0af8 | ||
|
|
29eecf55f7 | ||
|
|
0660ee74d4 | ||
|
|
5ade710f32 | ||
|
|
855ff82f97 | ||
|
|
8910c2e9c7 | ||
|
|
b2fe9ab068 | ||
|
|
8a529b4eb0 | ||
|
|
46b4e37178 | ||
|
|
5ad791fe50 | ||
|
|
8911c08a5b | ||
|
|
7d95240bc1 | ||
|
|
7e0d542694 | ||
|
|
84130dfff5 | ||
|
|
e0094447a6 | ||
|
|
ab31fc69fe | ||
|
|
73071ff1e8 | ||
|
|
4ad190a7f4 | ||
|
|
6ddb143365 | ||
|
|
eb4ca83a13 | ||
|
|
43c80d238b | ||
|
|
416ea871be | ||
|
|
5c2bd4ec74 | ||
|
|
be6af33d85 | ||
|
|
d02d9347dc | ||
|
|
64998b527c | ||
|
|
9e5b6df196 | ||
|
|
332053d8ec | ||
|
|
b6fcdd396d | ||
|
|
92ec774249 | ||
|
|
404d97d846 | ||
|
|
0132d1cbd8 | ||
|
|
ff6daa6ce3 | ||
|
|
6a5f3ef04d | ||
|
|
da047cf612 | ||
|
|
f0877d6d32 | ||
|
|
f2c46dbfb1 | ||
|
|
5f50d1ba00 | ||
|
|
7e19909dd6 | ||
|
|
9db4daac10 | ||
|
|
0a4cd6bd8c | ||
|
|
20c865ed20 | ||
|
|
e740bf2797 | ||
|
|
074958fad7 | ||
|
|
11d3ba8236 | ||
|
|
ce41745edb | ||
|
|
036121c148 | ||
|
|
dd0539392b | ||
|
|
50ba8310ca | ||
|
|
50fe286528 | ||
|
|
144efdc8ad | ||
|
|
e446bee900 | ||
|
|
a980befd60 | ||
|
|
2e0c598dae | ||
|
|
771a192a3d | ||
|
|
95cc65e28d | ||
|
|
b6e36ee0c4 | ||
|
|
25f3e1d2bd | ||
|
|
091eafa743 | ||
|
|
cbe665591f | ||
|
|
0abd9ff9bc | ||
|
|
618ced3b8f | ||
|
|
f7fd459723 | ||
|
|
bb22a818bf | ||
|
|
b66fe47487 | ||
|
|
98122fa4d1 | ||
|
|
c3f65b1a32 | ||
|
|
7da616ce4c | ||
|
|
9d914e70b9 | ||
|
|
6f0624e9b4 | ||
|
|
3cc67e11cd | ||
|
|
742a5e953e | ||
|
|
3b82cee4b1 | ||
|
|
2b010954a3 | ||
|
|
6f23565517 | ||
|
|
7c2896c3fe | ||
|
|
721d61bc4e | ||
|
|
014b236745 | ||
|
|
80c8d7e5ee | ||
|
|
8423881cc4 | ||
|
|
a1d644977c | ||
|
|
301fb536f2 | ||
|
|
49a8d772c8 | ||
|
|
a9adae29d2 | ||
|
|
d6e23baa19 | ||
|
|
658e7603f8 | ||
|
|
fb0b9b9910 | ||
|
|
e149e8b8b7 | ||
|
|
d2cc1503de | ||
|
|
f86803feff | ||
|
|
a2e3c7218d | ||
|
|
567d2b66fb | ||
|
|
473dbb9057 | ||
|
|
89bd47d5df | ||
|
|
10c4a9dafc | ||
|
|
f4d73bf8c9 | ||
|
|
441e2e9194 | ||
|
|
c15881c463 | ||
|
|
f5acbd2efe | ||
|
|
e07378c92b | ||
|
|
cd38d32a7d | ||
|
|
53f326390e | ||
|
|
651d24128c | ||
|
|
2660026917 | ||
|
|
ac7ace27cb | ||
|
|
1de008269b | ||
|
|
4bdbff0377 | ||
|
|
3415c06256 | ||
|
|
9f3da4c17a | ||
|
|
3d0c83557e | ||
|
|
e319eb7f20 | ||
|
|
7f007b7eb9 | ||
|
|
9e665005e6 | ||
|
|
b5b32b5f64 | ||
|
|
89a699b6cb | ||
|
|
79cbf2d2d1 | ||
|
|
ab00c9529e | ||
|
|
21500a6727 | ||
|
|
066b944789 | ||
|
|
d7dd816a26 | ||
|
|
c5b464c000 | ||
|
|
645c733393 | ||
|
|
85088e64ca | ||
|
|
896b3e45fc | ||
|
|
490f951b59 | ||
|
|
69c96255d7 | ||
|
|
487542e55d | ||
|
|
ca8fe76c06 | ||
|
|
e6e3619cd2 | ||
|
|
92ab67d5f3 | ||
|
|
77f5bef1a5 | ||
|
|
58e43a0862 | ||
|
|
80c1f2747a | ||
|
|
b7b2d8b050 | ||
|
|
b9eabd7cdc | ||
|
|
0a19465140 | ||
|
|
e874b5db18 | ||
|
|
23e7d97e3c | ||
|
|
a222de2c55 | ||
|
|
bb1eed1ccb | ||
|
|
c34bc8da69 | ||
|
|
249c4376f1 | ||
|
|
d36bde7d94 | ||
|
|
493d2a2707 | ||
|
|
93a9fbe5cc | ||
|
|
b070f33bf9 | ||
|
|
ee5480dba7 | ||
|
|
2e9ef3d5ba | ||
|
|
1061b84087 | ||
|
|
0dc80f7696 | ||
|
|
212e3c689b | ||
|
|
488ca451ae | ||
|
|
f50c167b37 | ||
|
|
4e91d62d48 | ||
|
|
a1b0b1e177 | ||
|
|
147dd2011a | ||
|
|
d24b71d4af | ||
|
|
483e3a94e9 | ||
|
|
e612f5ed8f | ||
|
|
78f3c8fd4b | ||
|
|
5a4e073d63 | ||
|
|
22cbfecfa9 | ||
|
|
d1b059fc2f | ||
|
|
cf5c410aa6 | ||
|
|
9c46673a1a | ||
|
|
45c27ae981 | ||
|
|
b62fb1f708 | ||
|
|
579c196643 | ||
|
|
e8da6e213a | ||
|
|
d9426e43a9 | ||
|
|
aad0274d56 | ||
|
|
d65f7ca924 | ||
|
|
a4d95640a5 | ||
|
|
adda3a2488 | ||
|
|
34654c0969 | ||
|
|
b708f9817d | ||
|
|
1210a1cbd9 | ||
|
|
24f1a673b9 | ||
|
|
a3162e1992 | ||
|
|
17f904e4ab | ||
|
|
1324f4fa21 | ||
|
|
cf15144bb9 | ||
|
|
946b27b3b8 | ||
|
|
aed5254cf0 | ||
|
|
7b8922b7ac | ||
|
|
733ad4c6c0 | ||
|
|
ef98ddd1d3 | ||
|
|
ca28c40aca | ||
|
|
0af5903bfe | ||
|
|
717b7b4a88 | ||
|
|
05eec9515b | ||
|
|
877852fc29 | ||
|
|
dbb9075a52 | ||
|
|
eafcf7fe76 | ||
|
|
094ce6c1ba | ||
|
|
2eed2cf7ba | ||
|
|
88b9d3bdac | ||
|
|
9c87227613 | ||
|
|
bc737b1ef9 | ||
|
|
911d990690 | ||
|
|
0f7cd5f438 | ||
|
|
354903b97b | ||
|
|
b432396111 | ||
|
|
491c0ad531 | ||
|
|
1540b78487 | ||
|
|
11f43b116d | ||
|
|
a0760c8e93 | ||
|
|
85ecfc3500 | ||
|
|
4c2308622a | ||
|
|
25b6a25f9a | ||
|
|
7138c22d02 | ||
|
|
a06a5c9e72 | ||
|
|
668eb63668 | ||
|
|
7d75f1ba37 | ||
|
|
2c7b59985e | ||
|
|
b616eeb2d4 | ||
|
|
0f0e359c63 | ||
|
|
1b83644788 | ||
|
|
9a9200744e | ||
|
|
e763bc12c4 | ||
|
|
8d7b47a7de | ||
|
|
d7b3c3443d | ||
|
|
8559083d66 | ||
|
|
96ef3c9fd5 | ||
|
|
3cace1fce0 | ||
|
|
f2adfcc052 | ||
|
|
b31bfa8ba9 | ||
|
|
b098509df4 | ||
|
|
2df23b6ddb | ||
|
|
6dbfdf7f89 | ||
|
|
5d8b0d9c91 | ||
|
|
fa00885d8b | ||
|
|
f3b52b3282 | ||
|
|
10ddde6592 | ||
|
|
9dff938db3 | ||
|
|
44ae7b218f | ||
|
|
f6fa61fe56 | ||
|
|
207c3fa75d | ||
|
|
027c6c8c14 | ||
|
|
709fafd64a | ||
|
|
772e43d5b4 | ||
|
|
2c0f01be1a | ||
|
|
f7bec7189c | ||
|
|
1730f849ed | ||
|
|
1d46194f99 | ||
|
|
8d652049af | ||
|
|
7af2a956d2 | ||
|
|
45b23a04c9 | ||
|
|
1a7c5990ae | ||
|
|
9133dac3d0 | ||
|
|
2709166a5b | ||
|
|
fcb5284d57 | ||
|
|
92d511253f | ||
|
|
9874c4d339 | ||
|
|
86e97c66b9 | ||
|
|
f2afed643e | ||
|
|
f502e3df76 | ||
|
|
7491da96e6 | ||
|
|
0b887689f0 | ||
|
|
9cce96c80c | ||
|
|
2711d27c03 | ||
|
|
db3f31aba5 | ||
|
|
29a7e3607f | ||
|
|
d7f6c2920d | ||
|
|
6bff7e86f6 | ||
|
|
a7a78a316e | ||
|
|
17ff74854a | ||
|
|
a101601cf4 | ||
|
|
1904192f55 | ||
|
|
7fd49ab55c | ||
|
|
3d7138ea30 | ||
|
|
835f8f17ec | ||
|
|
488c1afcab | ||
|
|
1fd59efe6c | ||
|
|
9cf0366aca | ||
|
|
15d7480547 | ||
|
|
819c55d691 | ||
|
|
67b4c5ebd8 | ||
|
|
4bd9fda728 | ||
|
|
33b8308b3f | ||
|
|
57cf9c243f | ||
|
|
f497a1e48b | ||
|
|
f23fa95a56 | ||
|
|
8e8033bb35 | ||
|
|
71b0903b0e | ||
|
|
d0eefe2dd2 | ||
|
|
2c547bab68 | ||
|
|
2dc04b5ecf | ||
|
|
e2dae1ec7b | ||
|
|
59d923e168 | ||
|
|
51844c27f1 | ||
|
|
53171affca | ||
|
|
d31d12e59f | ||
|
|
3715f2b3f9 | ||
|
|
cc3d54bff4 | ||
|
|
ef376a88b8 | ||
|
|
e639238f0e | ||
|
|
cf8241a77a | ||
|
|
eb87a4fc33 | ||
|
|
0727fb4343 | ||
|
|
d55d759fec | ||
|
|
aad279ce55 | ||
|
|
e9b1a6b2cd | ||
|
|
bd5a6934e1 | ||
|
|
2f04008453 | ||
|
|
fbaa410fd4 | ||
|
|
1d4f6f2814 | ||
|
|
ad2f5160fb | ||
|
|
d284405310 | ||
|
|
ff172ccbf4 | ||
|
|
14635948b6 | ||
|
|
012fd1dcc2 | ||
|
|
94fa5862b1 | ||
|
|
e7ec6c71f7 | ||
|
|
59b187b2a6 | ||
|
|
5e7cb0f9f1 | ||
|
|
dfe51b0dc5 | ||
|
|
7a4dd4bf66 | ||
|
|
0b34beea59 | ||
|
|
90ee9bc731 | ||
|
|
046f38e7c4 | ||
|
|
eb733cea45 | ||
|
|
eb68f0c629 | ||
|
|
1cbcac4a00 | ||
|
|
2d6d58edab | ||
|
|
c01fadbb48 | ||
|
|
41ef2e239d | ||
|
|
7176180343 | ||
|
|
0b9a01c8ac | ||
|
|
50ef62a64a | ||
|
|
384fa4bbe1 | ||
|
|
10f84a8052 | ||
|
|
53bd83af8b | ||
|
|
53577e21e7 | ||
|
|
46c0cb9bfb | ||
|
|
ac8ee8f9fa | ||
|
|
787e8bdd80 | ||
|
|
83a39e3143 | ||
|
|
5e2ed79929 | ||
|
|
c8f0ba926d | ||
|
|
17e0e14aef | ||
|
|
731602a80d | ||
|
|
37f0e449de | ||
|
|
56dd512d3c | ||
|
|
16f11b052a | ||
|
|
06bae7efcb | ||
|
|
39b017620f | ||
|
|
45e8a3a530 | ||
|
|
8dd64a9fa3 | ||
|
|
c1e8b0779f | ||
|
|
d16daf1862 | ||
|
|
491b092ac1 | ||
|
|
e2304c6b1b | ||
|
|
ea52f61a2c | ||
|
|
3db6edcc3d | ||
|
|
2045dd3f30 | ||
|
|
7a37c93a02 | ||
|
|
1d49300f8c | ||
|
|
f5641df0d9 | ||
|
|
b9c1e58703 | ||
|
|
22b9fada55 | ||
|
|
d34b286c4f | ||
|
|
6dab3c8450 | ||
|
|
32dd5c9414 | ||
|
|
2f09d28b94 | ||
|
|
77ce900bbf | ||
|
|
0675634b27 | ||
|
|
74db186ab3 | ||
|
|
1a5bc31b9b | ||
|
|
f6a94db702 | ||
|
|
6213ce7556 | ||
|
|
ff91e548b8 | ||
|
|
6d59070184 | ||
|
|
c8126e551f | ||
|
|
589eae0d3f | ||
|
|
8e25bc9ce3 | ||
|
|
9aa4dcc206 | ||
|
|
b2b5966699 | ||
|
|
e9c7e0031c | ||
|
|
1e3d29ffdb | ||
|
|
84670ac231 | ||
|
|
6b17c5c498 | ||
|
|
eed9820ce6 | ||
|
|
858d914ae3 | ||
|
|
be7c3812db | ||
|
|
44c65f1eb8 | ||
|
|
dd742c1038 | ||
|
|
d19b64ec1d | ||
|
|
6ea22e3241 | ||
|
|
ea99966e7a | ||
|
|
fa13d4d1f9 | ||
|
|
15e4f583e0 | ||
|
|
3fbbc7bdce | ||
|
|
5fcd1d313c | ||
|
|
967b7ab8a7 | ||
|
|
d113dde3cc | ||
|
|
60c24a5ee1 | ||
|
|
d5b8982763 | ||
|
|
88a441da23 | ||
|
|
03f27e7eb1 | ||
|
|
6bba4526f0 | ||
|
|
ebb84862a2 | ||
|
|
28480a93d4 | ||
|
|
5c7c5a4e99 | ||
|
|
90ee1da9c7 | ||
|
|
596dbd0c2d | ||
|
|
de5db97505 | ||
|
|
0db40294ba | ||
|
|
74cd074c98 | ||
|
|
bb43b25e37 | ||
|
|
b28431e4bd | ||
|
|
ee999810af | ||
|
|
860f498e21 | ||
|
|
798e900a9a | ||
|
|
9617cf575f | ||
|
|
0ebba0f591 | ||
|
|
01fd429ec3 | ||
|
|
701aacf45c | ||
|
|
2120168d1f | ||
|
|
171e4ceece | ||
|
|
1d4c394ad8 | ||
|
|
837313e146 | ||
|
|
3e415da8d4 | ||
|
|
a125466b75 | ||
|
|
a9306cd651 | ||
|
|
6af72f41c0 | ||
|
|
e9e04593c7 | ||
|
|
43b39c9f68 | ||
|
|
8d5ce55301 | ||
|
|
e72d461869 | ||
|
|
5cc4b518ba | ||
|
|
8aed055cc4 | ||
|
|
a306b6911a | ||
|
|
776dfee23b | ||
|
|
02e01a1d7c | ||
|
|
6b0b5b4855 | ||
|
|
9dd09731bd | ||
|
|
963c829164 | ||
|
|
d01fafbf0f | ||
|
|
729db05346 | ||
|
|
70dc06a761 | ||
|
|
be059b7aa3 | ||
|
|
b80c917f25 | ||
|
|
6b74bf90ce | ||
|
|
a2e4665ff5 | ||
|
|
2c9bd9c847 | ||
|
|
54eb957618 | ||
|
|
370f0ff815 | ||
|
|
9bd9a280e5 | ||
|
|
daf6ae291f | ||
|
|
3d774eec81 | ||
|
|
129e0be151 | ||
|
|
862657ea86 | ||
|
|
70b519a345 | ||
|
|
6600e4014d | ||
|
|
b8a6f18a77 | ||
|
|
03e08cdfbb | ||
|
|
b07f7345ee | ||
|
|
e85996178a | ||
|
|
5547fcd067 | ||
|
|
aef4344997 | ||
|
|
061197cd77 | ||
|
|
c00d1d0cb2 | ||
|
|
b6a7511e33 | ||
|
|
13450a50eb | ||
|
|
eb883ccc6f | ||
|
|
2ae9b3516c | ||
|
|
7dc2a0294e | ||
|
|
f97b91c19c | ||
|
|
bfac98dc67 | ||
|
|
0f0f9f5985 | ||
|
|
efebe66518 | ||
|
|
37a05e8f3d | ||
|
|
7473c438c2 | ||
|
|
6e4e6d0653 | ||
|
|
c09bec5c20 | ||
|
|
14ce2b8f53 | ||
|
|
8ecb6aa7aa | ||
|
|
cedc144173 | ||
|
|
434e09ae95 | ||
|
|
06ffc44530 | ||
|
|
a7c24e2323 | ||
|
|
726cfa513e | ||
|
|
62a1317d88 | ||
|
|
1221feae45 | ||
|
|
884c5686bd | ||
|
|
9b82a19528 | ||
|
|
2db4dfb942 | ||
|
|
09df09c29a | ||
|
|
215fdc822c | ||
|
|
1572b92751 | ||
|
|
8da28d6b95 | ||
|
|
6f24268683 | ||
|
|
abe9de2bd1 | ||
|
|
342aacd060 | ||
|
|
a95d621bdb | ||
|
|
68a8f01b32 | ||
|
|
ca07f1b904 | ||
|
|
1aa76be031 | ||
|
|
38be68455c | ||
|
|
2f3cfd5387 | ||
|
|
d3a99c0ff4 | ||
|
|
b4d5a40ca4 | ||
|
|
356e71ad22 | ||
|
|
d6b58f6eed | ||
|
|
785f448baf | ||
|
|
194987f66a | ||
|
|
bbff63bd7d | ||
|
|
f654b71b31 | ||
|
|
b85d1d1a48 | ||
|
|
d669d2862e | ||
|
|
e6e71716c8 | ||
|
|
5022148fd9 | ||
|
|
f069edc026 | ||
|
|
65cf7b30c3 | ||
|
|
5110b42f0a | ||
|
|
92475f7331 | ||
|
|
12ad38fc78 | ||
|
|
dbf6e24d68 | ||
|
|
19d42a41dd | ||
|
|
573fbdb6d8 | ||
|
|
52a7024e82 | ||
|
|
600ae2e36f | ||
|
|
d7b744a01a | ||
|
|
a3d4f8ca6a | ||
|
|
18ad4fe9de | ||
|
|
efb4cc6a4d | ||
|
|
09dd2ad329 | ||
|
|
5f079c46d6 | ||
|
|
ec899e3ee4 | ||
|
|
f7813e4b1b | ||
|
|
f5d00c0a2a | ||
|
|
9b9348b341 | ||
|
|
b5266cda66 | ||
|
|
d6d716c5ab | ||
|
|
de5a611718 | ||
|
|
d595acbde3 | ||
|
|
638ce9ae6c | ||
|
|
bd2c67d725 | ||
|
|
f8ae5418c9 | ||
|
|
5e1959dec3 | ||
|
|
b7a276dd05 | ||
|
|
b2b3db616a | ||
|
|
17e1a8828c | ||
|
|
1674220340 | ||
|
|
ab76d51b84 | ||
|
|
4863e623ae | ||
|
|
ac88159d56 |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
name: Build frontend theme
|
||||
description: Clone komari-web, build the default theme, and optionally upload it as an artifact.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version used to build komari-web.
|
||||
default: "23"
|
||||
repository:
|
||||
description: komari-web repository URL.
|
||||
default: https://github.com/komari-monitor/komari-web
|
||||
ref:
|
||||
description: Optional komari-web ref to build. Uses the repository default branch when empty.
|
||||
default: ""
|
||||
artifact-name:
|
||||
description: Optional artifact name for the packed frontend theme.
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Clone and build frontend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
rm -rf komari-web
|
||||
if [ -n "${{ inputs.ref }}" ]; then
|
||||
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
|
||||
else
|
||||
git clone --depth=1 "${{ inputs.repository }}" komari-web
|
||||
fi
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Pack frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
tar -czf "$RUNNER_TEMP/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme .
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ inputs.artifact-name }}.tar.gz
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Restore frontend theme
|
||||
description: Download and unpack a previously built frontend theme artifact.
|
||||
|
||||
inputs:
|
||||
artifact-name:
|
||||
description: Frontend theme artifact name.
|
||||
default: komari-frontend
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/frontend-artifact
|
||||
|
||||
- name: Extract frontend artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p web/public/defaultTheme
|
||||
rm -rf web/public/defaultTheme/*
|
||||
tar -xzf "$RUNNER_TEMP/frontend-artifact/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Setup Zig
|
||||
description: Restore or install the Zig toolchain used for CGO cross-compilation.
|
||||
|
||||
inputs:
|
||||
zig-version:
|
||||
description: Zig version to install.
|
||||
default: 0.14.1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore Zig cache
|
||||
id: zig-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}
|
||||
key: zig-${{ runner.os }}-x86_64-${{ inputs.zig-version }}
|
||||
|
||||
- name: Download Zig
|
||||
if: ${{ steps.zig-cache.outputs.cache-hit != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p "$HOME/.cache/komari-zig"
|
||||
curl -fsSL "https://ziglang.org/download/${{ inputs.zig-version }}/zig-x86_64-linux-${{ inputs.zig-version }}.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
|
||||
tar -C "$HOME/.cache/komari-zig" -Jxf "$RUNNER_TEMP/zig.tar.xz"
|
||||
|
||||
- name: Add Zig to PATH
|
||||
shell: bash
|
||||
run: |
|
||||
echo "$HOME/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}" >> "$GITHUB_PATH"
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Auto Merge Dev to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize] # No longer need 'closed' trigger
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
# Job to enable Auto-Merge for the PR
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for open PRs from dev to main
|
||||
if: github.head_ref == 'dev' && github.base_ref == 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Required for git history
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Get PR number
|
||||
id: get-pr
|
||||
run: |
|
||||
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for conflicts
|
||||
id: check-conflicts
|
||||
run: |
|
||||
# Ensure local branches are up-to-date
|
||||
git fetch origin main
|
||||
git fetch origin dev
|
||||
|
||||
# Use merge-base to find the common ancestor and check for conflicts
|
||||
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "❌ Merge conflicts detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ No merge conflicts"
|
||||
fi
|
||||
|
||||
- name: Add conflict comment
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "❌ **Merge Conflict Detection**
|
||||
|
||||
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
|
||||
|
||||
Steps to resolve conflicts:
|
||||
1. \`git checkout dev\`
|
||||
2. \`git merge main\`
|
||||
3. Resolve conflicts
|
||||
4. \`git commit\`
|
||||
5. \`git push origin dev\`"
|
||||
exit 1 # Exit to prevent further steps
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Enable auto-merge using a merge commit
|
||||
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
|
||||
# Add success comment
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "✅ **Auto-merge Enabled**
|
||||
|
||||
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
|
||||
|
||||
Merge method: Merge commit
|
||||
Branch retention: dev branch will be preserved"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
|
||||
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Build Binaries on PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
# Append the appropriate executable extension based on the OS
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Cleanup Old Packages
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
#schedule:
|
||||
# - cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
# 会清理掉正常的镜像
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
@@ -20,40 +21,41 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
|
||||
// 分页获取所有包版本
|
||||
const allVersions = [];
|
||||
let page = 1;
|
||||
let fetched;
|
||||
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
|
||||
do {
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
page,
|
||||
per_page: 100
|
||||
});
|
||||
fetched = packages.length;
|
||||
allVersions.push(...packages);
|
||||
page++;
|
||||
} while (fetched === 100);
|
||||
|
||||
// 筛选仅有 SHA/sha256 标签或无标签的版本
|
||||
const shaOrNoTagVersions = allVersions.filter(pkg => {
|
||||
const tags = pkg.metadata.container.tags;
|
||||
return (
|
||||
(tags.length === 1 && (
|
||||
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
|
||||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
|
||||
)) ||
|
||||
tags.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
// Keep packages with 'dev' or 'latest' tags, plus the 10 most recent non-dev, non-latest versions
|
||||
const keepVersions = [
|
||||
// Keep packages with 'dev' tag
|
||||
...packages.filter(pkg => pkg.metadata.container.tags.includes('dev')),
|
||||
// Keep packages with 'latest' tag, excluding those already included with 'dev'
|
||||
...packages.filter(
|
||||
pkg =>
|
||||
pkg.metadata.container.tags.includes('latest') &&
|
||||
!pkg.metadata.container.tags.includes('dev')
|
||||
),
|
||||
// Keep the 10 most recent non-dev, non-latest packages
|
||||
...packages
|
||||
.filter(
|
||||
pkg =>
|
||||
!pkg.metadata.container.tags.includes('dev') &&
|
||||
!pkg.metadata.container.tags.includes('latest')
|
||||
)
|
||||
.slice(0, 10),
|
||||
];
|
||||
|
||||
// Identify versions to delete (not in keepVersions)
|
||||
const deleteVersions = packages.filter(pkg => !keepVersions.includes(pkg));
|
||||
|
||||
// Delete the versions
|
||||
for (const version of deleteVersions) {
|
||||
// 删除这些版本
|
||||
for (const version of shaOrNoTagVersions) {
|
||||
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
|
||||
package_name: packageName,
|
||||
package_version_id: version.id
|
||||
});
|
||||
console.log(`Deleted package version ${version.id}`);
|
||||
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -12,22 +12,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
|
||||
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -69,7 +62,7 @@ jobs:
|
||||
- name: Add host to known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
|
||||
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Docker Build and Publish
|
||||
name: Publish Docker Image on Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -11,7 +11,61 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-docker-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,6 +75,18 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -42,8 +108,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
@@ -0,0 +1,608 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to regenerate notes for. Leave empty to use the latest release."
|
||||
required: false
|
||||
type: string
|
||||
base_ref:
|
||||
description: "Base ref or SHA for the release comparison. Snapshot builds pass the previous main SHA."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'Snapshot-') }}
|
||||
|
||||
steps:
|
||||
- name: Generate and apply release notes
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const webRepo = { owner: "komari-monitor", repo: "komari-web" };
|
||||
|
||||
const AI_CONTEXT_CHAR_LIMIT = 120000;
|
||||
const AI_MAX_COMMITS = 160;
|
||||
const AI_MAX_CHANGED_FILES = 120;
|
||||
const AI_MAX_PULL_REQUESTS = 80;
|
||||
const AI_MAX_PATCH_CHARS_PER_FILE = 2200;
|
||||
const OPENAI_TIMEOUT_MS = 120000;
|
||||
|
||||
const clean = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
const isSnapshotTag = (tag) => /^Snapshot-/i.test(clean(tag));
|
||||
const releaseTimestamp = (release) => Date.parse(release.published_at || release.created_at || "");
|
||||
const isStableRelease = (release) => !release.draft && !release.prerelease && !isSnapshotTag(release.tag_name);
|
||||
|
||||
function limitText(value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
|
||||
}
|
||||
|
||||
function appendWithinLimit(parts, value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentLength = parts.reduce((total, part) => total + part.length + 2, 0);
|
||||
if (currentLength + text.length + 2 <= maxLength) {
|
||||
parts.push(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
const remaining = maxLength - currentLength - 64;
|
||||
if (remaining > 500) {
|
||||
parts.push(`${text.slice(0, remaining)}\n...[context truncated]`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRelease() {
|
||||
const requestedTag = clean(context.payload.release?.tag_name)
|
||||
|| clean(context.payload.client_payload?.tag)
|
||||
|| clean(context.payload.inputs?.tag);
|
||||
|
||||
if (requestedTag) {
|
||||
const release = context.payload.release?.tag_name === requestedTag
|
||||
? context.payload.release
|
||||
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag: requestedTag })).data;
|
||||
return { tag: requestedTag, release };
|
||||
}
|
||||
|
||||
const release = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
|
||||
return { tag: release.tag_name, release };
|
||||
}
|
||||
|
||||
async function getPreviousReleaseTag(tag, release) {
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const currentTime = releaseTimestamp(release);
|
||||
const currentIsSnapshot = isSnapshotTag(tag);
|
||||
const currentIsStable = isStableRelease(release);
|
||||
const candidates = releases
|
||||
.filter((item) => !item.draft && item.tag_name !== tag)
|
||||
.filter((item) => {
|
||||
if (!Number.isFinite(currentTime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemTime = releaseTimestamp(item);
|
||||
return Number.isFinite(itemTime) && itemTime < currentTime;
|
||||
})
|
||||
.filter((item) => {
|
||||
if (currentIsSnapshot || currentIsStable) {
|
||||
return isStableRelease(item);
|
||||
}
|
||||
|
||||
return item.prerelease && !isSnapshotTag(item.tag_name);
|
||||
})
|
||||
.sort((left, right) => releaseTimestamp(right) - releaseTimestamp(left));
|
||||
|
||||
return candidates[0]?.tag_name || "";
|
||||
}
|
||||
|
||||
async function resolveComparisonBase(tag, release) {
|
||||
const requestedBaseRef = clean(context.payload.client_payload?.base_ref)
|
||||
|| clean(context.payload.inputs?.base_ref);
|
||||
|
||||
if (requestedBaseRef) {
|
||||
return {
|
||||
ref: requestedBaseRef,
|
||||
previousTag: "",
|
||||
source: "requested base ref",
|
||||
};
|
||||
}
|
||||
|
||||
const previousTag = await getPreviousReleaseTag(tag, release);
|
||||
if (previousTag) {
|
||||
return {
|
||||
ref: previousTag,
|
||||
previousTag,
|
||||
source: isSnapshotTag(tag) ? "previous stable release" : "previous release in the same channel",
|
||||
};
|
||||
}
|
||||
|
||||
if (isSnapshotTag(tag)) {
|
||||
try {
|
||||
const commit = (await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: release.target_commitish || tag,
|
||||
})).data;
|
||||
const parentSha = commit.parents?.[0]?.sha || "";
|
||||
if (parentSha) {
|
||||
return {
|
||||
ref: parentSha,
|
||||
previousTag: "",
|
||||
source: "snapshot target parent commit",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to resolve parent commit for ${tag}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ref: "",
|
||||
previousTag: "",
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeneratedNotes(tag, release, previousTag) {
|
||||
const params = {
|
||||
owner,
|
||||
repo,
|
||||
tag_name: tag,
|
||||
target_commitish: release.target_commitish || undefined,
|
||||
};
|
||||
|
||||
if (previousTag) {
|
||||
params.previous_tag_name = previousTag;
|
||||
}
|
||||
|
||||
const generated = await github.request(
|
||||
"POST /repos/{owner}/{repo}/releases/generate-notes",
|
||||
params,
|
||||
);
|
||||
|
||||
return clean(generated.data.body);
|
||||
}
|
||||
|
||||
async function getComparison(baseRef, tag) {
|
||||
if (!baseRef) {
|
||||
return { commits: [], files: [], htmlUrl: "" };
|
||||
}
|
||||
|
||||
const commits = [];
|
||||
let files = [];
|
||||
let htmlUrl = "";
|
||||
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const response = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseRef}...${tag}`,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
files = response.data.files || [];
|
||||
htmlUrl = response.data.html_url || "";
|
||||
}
|
||||
|
||||
commits.push(...response.data.commits);
|
||||
|
||||
if (!response.headers.link?.includes('rel="next"')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { commits, files, htmlUrl };
|
||||
}
|
||||
|
||||
function renderCommitLine(commit) {
|
||||
const subject = clean(commit.commit.message.split("\n")[0]) || "(no subject)";
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login
|
||||
? ` @${commit.author.login}`
|
||||
: (commit.commit.author?.name ? ` ${commit.commit.author.name}` : "");
|
||||
|
||||
return `- ${subject} ([${shortSha}](${commit.html_url}))${author}`;
|
||||
}
|
||||
|
||||
function buildWhatsChangedSection(baseRef, tag, comparison) {
|
||||
if (!baseRef) {
|
||||
return "## What's Changed\n\nNo comparison base was found for this release.";
|
||||
}
|
||||
|
||||
if (comparison.commits.length === 0) {
|
||||
return `## What's Changed\n\nNo commits found between \`${baseRef}\` and \`${tag}\`.`;
|
||||
}
|
||||
|
||||
const lines = ["## What's Changed", "", ...comparison.commits.map(renderCommitLine)];
|
||||
if (comparison.htmlUrl) {
|
||||
lines.push("", `**Full Changelog**: ${comparison.htmlUrl}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendCommitsToGeneratedNotes(generatedNotes, baseRef, tag, comparison) {
|
||||
const notes = clean(generatedNotes);
|
||||
if (!notes || comparison.commits.length === 0) {
|
||||
return notes || buildWhatsChangedSection(baseRef, tag, comparison);
|
||||
}
|
||||
|
||||
const lines = notes.split("\n");
|
||||
const fullChangelogIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/i.test(line.trim()));
|
||||
const nextTopLevelSectionIndex = lines.findIndex((line, index) => (
|
||||
index > 0
|
||||
&& /^##\s+/.test(line)
|
||||
&& !/^##\s+What's Changed\b/i.test(line)
|
||||
));
|
||||
const insertionIndex = nextTopLevelSectionIndex >= 0
|
||||
? nextTopLevelSectionIndex
|
||||
: (fullChangelogIndex >= 0 ? fullChangelogIndex : lines.length);
|
||||
const commitSection = [
|
||||
"### Commits",
|
||||
"",
|
||||
...comparison.commits.map(renderCommitLine),
|
||||
"",
|
||||
];
|
||||
|
||||
lines.splice(insertionIndex, 0, ...commitSection);
|
||||
return clean(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function getPullRequestContext(commits) {
|
||||
const pullRequests = new Map();
|
||||
|
||||
for (const commit of commits.slice(0, AI_MAX_COMMITS)) {
|
||||
try {
|
||||
const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: commit.sha,
|
||||
});
|
||||
|
||||
for (const pullRequest of response.data || []) {
|
||||
pullRequests.set(pullRequest.html_url, pullRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to load pull requests for ${commit.sha.slice(0, 7)}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (pullRequests.size >= AI_MAX_PULL_REQUESTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...pullRequests.values()].slice(0, AI_MAX_PULL_REQUESTS);
|
||||
}
|
||||
|
||||
function buildChangedFilesContext(files) {
|
||||
if (!files.length) {
|
||||
return "No changed file metadata was returned by GitHub.";
|
||||
}
|
||||
|
||||
return files.slice(0, AI_MAX_CHANGED_FILES).map((file) => {
|
||||
const lines = [
|
||||
`File: ${file.filename}`,
|
||||
`Status: ${file.status}; additions: ${file.additions}; deletions: ${file.deletions}; changes: ${file.changes}`,
|
||||
];
|
||||
|
||||
if (file.patch) {
|
||||
lines.push("Patch excerpt:");
|
||||
lines.push(limitText(file.patch, AI_MAX_PATCH_CHARS_PER_FILE));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildCommitContext(commits) {
|
||||
if (!commits.length) {
|
||||
return "No commits were returned by GitHub.";
|
||||
}
|
||||
|
||||
return commits.slice(0, AI_MAX_COMMITS).map((commit) => {
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login || commit.commit.author?.name || "unknown";
|
||||
const message = limitText(commit.commit.message, 1800);
|
||||
return [
|
||||
`Commit: ${shortSha}`,
|
||||
`Author: ${author}`,
|
||||
`URL: ${commit.html_url}`,
|
||||
"Message:",
|
||||
message,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildPullRequestContext(pullRequests) {
|
||||
if (!pullRequests.length) {
|
||||
return "No associated pull requests were found for the compared commits.";
|
||||
}
|
||||
|
||||
return pullRequests.map((pullRequest) => {
|
||||
const labels = (pullRequest.labels || []).map((label) => label.name).filter(Boolean).join(", ") || "none";
|
||||
return [
|
||||
`PR: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`URL: ${pullRequest.html_url}`,
|
||||
`Author: ${pullRequest.user?.login || "unknown"}`,
|
||||
`Labels: ${labels}`,
|
||||
"Body:",
|
||||
limitText(pullRequest.body, 1800) || "(empty)",
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiContext({ tag, release, comparisonBase, whatsChangedSection, comparison, pullRequests, webReleaseSection }) {
|
||||
const sections = [];
|
||||
|
||||
appendWithinLimit(sections, [
|
||||
`Repository: ${owner}/${repo}`,
|
||||
`Release tag: ${tag}`,
|
||||
`Release title: ${release.name || tag}`,
|
||||
`Prerelease: ${release.prerelease ? "yes" : "no"}`,
|
||||
`Snapshot tag: ${isSnapshotTag(tag) ? "yes" : "no"}`,
|
||||
`Target commitish: ${release.target_commitish || "(unknown)"}`,
|
||||
`Comparison base: ${comparisonBase.ref || "(none)"}`,
|
||||
`Comparison base source: ${comparisonBase.source}`,
|
||||
`Compare URL: ${comparison.htmlUrl || "(unavailable)"}`,
|
||||
].join("\n"), AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
appendWithinLimit(sections, `What's Changed section:\n${whatsChangedSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Changed files and patch excerpts:\n${buildChangedFilesContext(comparison.files)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Associated pull requests:\n${buildPullRequestContext(pullRequests)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Commit details:\n${buildCommitContext(comparison.commits)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
if (webReleaseSection) {
|
||||
appendWithinLimit(sections, `Komari Web release notes for the same tag:\n${webReleaseSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
}
|
||||
|
||||
return sections.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildChatCompletionsUrl(baseUrl) {
|
||||
const trimmed = clean(baseUrl).replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/chat/completions")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
return `${trimmed}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function normalizeReleaseSummary(markdown) {
|
||||
const summary = clean(markdown);
|
||||
if (!summary) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutAiHeading = clean(summary
|
||||
.replace(/^#{1,6}\s+AI Summary\b\s*/im, "")
|
||||
.replace(/^\*\*AI Summary\*\*\s*/im, ""));
|
||||
const whatsChangedIndex = withoutAiHeading.search(/^##\s+What's Changed\b/im);
|
||||
if (whatsChangedIndex >= 0) {
|
||||
return clean(withoutAiHeading.slice(0, whatsChangedIndex));
|
||||
}
|
||||
|
||||
return withoutAiHeading;
|
||||
}
|
||||
|
||||
async function callOpenAiChatCompletion(url, apiKey, model, messages) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model, messages, temperature: 0 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseJson = {};
|
||||
try {
|
||||
responseJson = responseText ? JSON.parse(responseText) : {};
|
||||
} catch {
|
||||
responseJson = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = clean(responseJson.error?.message) || limitText(responseText, 500);
|
||||
throw new Error(`OpenAI request failed with ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
return clean(responseJson.choices?.[0]?.message?.content);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReleaseSummarySection({ tag, release, comparisonBase, whatsChangedSection, comparison, webReleaseSection }) {
|
||||
const apiKey = clean(process.env.OPENAI_API_KEY);
|
||||
const baseUrl = clean(process.env.OPENAI_BASE_URL);
|
||||
const model = clean(process.env.OPENAI_MODEL);
|
||||
|
||||
if (!apiKey || !baseUrl || !model) {
|
||||
core.warning("OPENAI_API_KEY, OPENAI_BASE_URL, or OPENAI_MODEL is missing. Skipping generated release summary.");
|
||||
return "";
|
||||
}
|
||||
|
||||
const pullRequests = await getPullRequestContext(comparison.commits);
|
||||
const aiContext = buildAiContext({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
pullRequests,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"You write accurate release notes for the Komari server project.",
|
||||
"Use only the supplied repository context.",
|
||||
"Include both Komari server changes and Komari Web frontend changes when frontend release notes are supplied.",
|
||||
"Prioritize user-visible behavior, compatibility or migration notes, operations/deployment changes, security fixes, and important bug fixes.",
|
||||
"Do not invent features or implementation details that are not supported by the context.",
|
||||
"Write concise Markdown in Simplified Chinese.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
`Create the release summary section for release ${tag}.`,
|
||||
comparisonBase.ref
|
||||
? `Summarize only changes in the comparison ${comparisonBase.ref}...${tag}.`
|
||||
: "No comparison base was found, so summarize only the supplied release context.",
|
||||
"Return only Markdown for the summary section that appears before '## What's Changed'.",
|
||||
"Do not use the heading 'AI Summary'. Do not add a 'What's Changed' section.",
|
||||
"Use these top-level headings, in this order: '## 主要更新内容', '## Bug修复', and '## 其他'.",
|
||||
"Omit '## Bug修复' when there are no concrete bug fixes. Keep bullets short and specific.",
|
||||
"",
|
||||
aiContext,
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const url = buildChatCompletionsUrl(baseUrl);
|
||||
const summary = await callOpenAiChatCompletion(url, apiKey, model, messages);
|
||||
return normalizeReleaseSummary(summary);
|
||||
} catch (error) {
|
||||
core.warning(`Generated release summary failed: ${error.message}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWebReleaseSection(tag) {
|
||||
const demoteMarkdownHeadings = (markdown) => markdown.replace(/^(#{1,5})(\s+)/gm, "#$1$2");
|
||||
|
||||
try {
|
||||
const webRelease = (await github.rest.repos.getReleaseByTag({
|
||||
owner: webRepo.owner,
|
||||
repo: webRepo.repo,
|
||||
tag,
|
||||
})).data;
|
||||
|
||||
const webBody = clean(webRelease.body);
|
||||
if (!webBody) {
|
||||
return `## Komari Web Release Notes\n\nKomari Web release [${tag}](${webRelease.html_url}) exists, but its release notes are empty.`;
|
||||
}
|
||||
|
||||
return [
|
||||
"## Komari Web Release Notes",
|
||||
"",
|
||||
`Source: [komari-web ${tag}](${webRelease.html_url})`,
|
||||
"",
|
||||
demoteMarkdownHeadings(webBody),
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Komari Web release ${tag} does not exist yet. Skipping web release notes for now.`);
|
||||
return "";
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const { tag, release } = await resolveRelease();
|
||||
if (!tag) {
|
||||
core.setFailed("No release tag was provided or found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comparisonBase = await resolveComparisonBase(tag, release);
|
||||
core.info(comparisonBase.ref
|
||||
? `Comparison base for ${tag}: ${comparisonBase.ref} (${comparisonBase.source}).`
|
||||
: `No comparison base found for ${tag}.`);
|
||||
|
||||
const comparison = await getComparison(comparisonBase.ref, tag);
|
||||
const whatsChangedSection = comparisonBase.previousTag
|
||||
? appendCommitsToGeneratedNotes(
|
||||
await getGeneratedNotes(tag, release, comparisonBase.previousTag),
|
||||
comparisonBase.ref,
|
||||
tag,
|
||||
comparison,
|
||||
)
|
||||
: (!comparisonBase.ref && !isSnapshotTag(tag)
|
||||
? await getGeneratedNotes(tag, release, "")
|
||||
: buildWhatsChangedSection(comparisonBase.ref, tag, comparison));
|
||||
|
||||
if (!whatsChangedSection) {
|
||||
core.setFailed(`Failed to build What's Changed section for ${tag}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webReleaseSection = await buildWebReleaseSection(tag);
|
||||
const releaseSummarySection = await buildReleaseSummarySection({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const body = [
|
||||
releaseSummarySection,
|
||||
whatsChangedSection,
|
||||
].filter(Boolean).join("\n\n");
|
||||
|
||||
if (clean(release.body) === clean(body)) {
|
||||
core.info(`Release notes for ${tag} are already up to date.`);
|
||||
} else {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Release Notes")
|
||||
.addRaw(`Updated release notes for ${tag}.`)
|
||||
.write();
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Rebuild Existing Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing GitHub Release tag whose assets and version image will be replaced.
|
||||
required: true
|
||||
type: string
|
||||
server_ref:
|
||||
description: Server commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
frontend_ref:
|
||||
description: komari-web commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout workflow source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build selected frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ inputs.frontend_ref }}
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: komari-monitor/komari/.github/actions/restore-frontend@main
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: komari-monitor/komari/.github/actions/setup-zig@main
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
VERSION_HASH="$(git rev-parse HEAD)"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
|
||||
build-and-push-docker:
|
||||
needs: build-and-attach-binaries
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Build and push version Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Publish Docker Image Manually
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Docker image tag and embedded version.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-docker-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
+136
-28
@@ -1,4 +1,4 @@
|
||||
name: Build and Attach Binaries to Release
|
||||
name: Attach Release Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -13,70 +13,159 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-attach-binaries:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- id: release
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
frontend_ref=""
|
||||
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
|
||||
frontend_ref="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-frontend:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
node-version: '23'
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}.exe
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Define the binary name for upload
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}.exe
|
||||
fi
|
||||
# Upload the binary as a release asset using GitHub CLI
|
||||
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
needs: build-and-attach-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -84,22 +173,41 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Check whether this is the latest GitHub release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
is_latest="$(gh api graphql \
|
||||
-f 'query=query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { isLatest } } }' \
|
||||
-F owner="${{ github.repository_owner }}" \
|
||||
-F name="${{ github.event.repository.name }}" \
|
||||
-F tag="$TAG" \
|
||||
--jq '.data.repository.release.isLatest')"
|
||||
echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
|
||||
- name: Promote Docker latest tag
|
||||
if: steps.release.outputs.is_latest == 'true'
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
@@ -0,0 +1,293 @@
|
||||
name: Publish Snapshot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-ref:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure snapshot target is main
|
||||
run: |
|
||||
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Snapshot builds must run from refs/heads/main. Current ref: ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot builds must use the current main commit."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Generate snapshot version
|
||||
id: version
|
||||
run: |
|
||||
# Snapshot 版本号格式: Snapshot-yymmddhhMM (UTC+8)
|
||||
VERSION="Snapshot-$(date -u -d '+8 hours' +%y%m%d%H%M)"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
|
||||
build-frontend:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: [prepare, build-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
publish-snapshot-release:
|
||||
needs: [prepare, build-and-attach-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download all binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create snapshot prerelease and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
ls -alR artifacts
|
||||
|
||||
# 如果同名 release/tag 已存在(例如重跑),先删除避免创建冲突
|
||||
if gh release view "${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release ${VERSION} 已存在,正在删除以重新创建..."
|
||||
gh release delete "${VERSION}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
fi
|
||||
|
||||
# 创建预发布并上传所有二进制文件
|
||||
gh release create "${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "${VERSION}" \
|
||||
--notes "Automated snapshot build (${VERSION}) from commit ${{ github.sha }}." \
|
||||
--prerelease \
|
||||
artifacts/komari-*
|
||||
|
||||
gh workflow run generate-release-notes.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref main \
|
||||
-f tag="${VERSION}"
|
||||
|
||||
gh release list \
|
||||
--repo "${{ github.repository }}" \
|
||||
--limit 1000 \
|
||||
--json tagName,isPrerelease \
|
||||
--jq '.[] | select(.isPrerelease and (.tagName | startswith("Snapshot-"))) | .tagName' |
|
||||
while IFS= read -r tag; do
|
||||
if [ -z "${tag}" ] || [ "${tag}" = "${VERSION}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Deleting old snapshot release ${tag}..."
|
||||
gh release delete "${tag}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
done
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
needs: [prepare, build-and-attach-binaries, publish-snapshot-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot Docker publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=snapshot
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -7,12 +7,14 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
__debug*
|
||||
|
||||
# local development data
|
||||
komari.db
|
||||
/data
|
||||
/utils/geoip/data
|
||||
.vscode/
|
||||
/backup
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -29,3 +31,8 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
/dist/
|
||||
.codex
|
||||
+12
-35
@@ -1,44 +1,21 @@
|
||||
FROM node:23-alpine AS frontend
|
||||
WORKDIR /web
|
||||
RUN apk add --no-cache git
|
||||
RUN git clone https://github.com/komari-monitor/komari-web .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.24 AS builder
|
||||
WORKDIR /app
|
||||
# RUN apt-get update && apt-get install -y musl-tools
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /web/dist ./public/dist
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ARG VERSION=unknown
|
||||
ARG VERSION_HASH=unknown
|
||||
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
|
||||
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
|
||||
|
||||
# FROM scratch
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/komari .
|
||||
# Docker buildx 会在构建时自动填充这些变量
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
|
||||
ENV GIN_MODE=release
|
||||
# 数据库配置环境变量(可以在运行时覆盖)
|
||||
ENV KOMARI_DB_TYPE=sqlite
|
||||
ENV KOMARI_DB_FILE=/app/data/komari.db
|
||||
ENV KOMARI_DB_HOST=localhost
|
||||
ENV KOMARI_DB_PORT=3306
|
||||
ENV KOMARI_DB_USER=root
|
||||
ENV KOMARI_DB_PASS=
|
||||
ENV KOMARI_DB_NAME=komari
|
||||
ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
# 使用环境变量启动服务
|
||||
CMD ["/app/komari","server"]
|
||||
CMD ["/app/komari", "server"]
|
||||
@@ -0,0 +1,27 @@
|
||||
CoreOS Project
|
||||
Copyright 2014 CoreOS, Inc
|
||||
|
||||
This product includes software developed at CoreOS, Inc.
|
||||
(http://www.coreos.com/).
|
||||
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
|
||||
Copyright 2014 gRPC authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,131 +1,66 @@
|
||||
|
||||
# Komari
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||

|
||||
[](https://deepwiki.com/komari-monitor/komari)
|
||||
|
||||
[文档](https://komari-monitor.github.io/komari-document/)
|
||||

|
||||
|
||||
## 特性
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
[English](./README.md) | [简体中文](./README_zh-cn.md)
|
||||
|
||||
## 快速开始
|
||||
Komari is a lightweight, self-hosted server monitoring solution. It provides a simple and efficient way to track server performance through a web interface, with metrics collected by a lightweight agent.
|
||||
|
||||
### 依赖
|
||||
- Docker(快速部署)
|
||||
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
|
||||
> [!WARNING]
|
||||
> Komari is a self-hosted monitoring and control application. Deploy it only on systems you own or are authorized to manage. You are solely responsible for how you deploy and use Komari. The developers accept no liability for unauthorized access, persistence, command execution, other misuse, or any resulting consequences.
|
||||
|
||||
### Docker 部署
|
||||
1. 创建数据目录:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. 运行 Docker 容器:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. 查看默认账号和密码:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
|
||||
[Documentation](https://www.komari.wiki/) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
## Features
|
||||
|
||||
### 二进制文件部署
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
|
||||
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
|
||||
- **Real-time monitoring**: Displays monitoring data at one-second intervals.
|
||||
- **Lightweight and efficient**: Uses minimal system resources and works well on servers of any size.
|
||||
- **Self-hosted**: Keeps you in control of your data and privacy.
|
||||
- **Web interface**: Provides an intuitive, easy-to-use monitoring dashboard.
|
||||
- **Extensible**: Supports custom themes and plugins.
|
||||
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
## Quick Start
|
||||
|
||||
| Platform | Description |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | Deploy websites, databases, and hundreds of popular apps in seconds with flexible hourly billing. |
|
||||
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel App Store" width="180"></a> | A modern, open-source Linux server management panel for websites, databases, containers, files, backups, security, and AI, with one-click deployment from its app store. |
|
||||
|
||||
### 手工构建
|
||||
1. 构建前端静态文件:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. 构建后端:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
默认监听 `25774` 端口,访问 `http://localhost:25774`。
|
||||
For instructions on Docker deployment, binary installation, building from source, and updates, see the [installation guide](https://www.komari.wiki/en/install/quick-start).
|
||||
|
||||
## 前端开发指南
|
||||
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
|
||||
## Screenshots
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
这个坑晚点再填吧 (o゜▽゜)o☆
|
||||
| Page | Screenshot |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Home Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Home Dashboard"> |
|
||||
| Admin Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Admin Dashboard"> |
|
||||
| History Charts | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8-en.webp" width="800" alt="History Charts"> |
|
||||
| Web Terminal | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="Web Terminal"> |
|
||||
| Customizable Themes | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89-en.webp" width="800" alt="Customizable Themes"> |
|
||||
| Theme Market | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA-en.webp" width="800" alt="Theme Market"> |
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
## Sponsors
|
||||
|
||||
## 鸣谢
|
||||
- 感谢 [Alice Networks LTD](https://app.alice.ws/) 提供的免费服务器
|
||||
Interested in sponsoring Komari? Contact the developer via [email](mailto:komari@akz.moe) or [Telegram](https://t.me/mamomoe).
|
||||
|
||||
## 引用
|
||||
- [gorm.io](https://gorm.io/)
|
||||
- [spf13/cobra](https://github.com/spf13/cobra)
|
||||
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
|
||||
- [gorilla/websocket](https://github.com/gorilla/websocket)
|
||||
- [google/uuid](https://github.com/google/uuid)
|
||||
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
|
||||
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
|
||||
- [creack/pty](https://github.com/creack/pty)
|
||||
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
|
||||
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
|
||||
| Sponsor | Description |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | Cost-effective Asia-Pacific hosting with direct connectivity and robust DDoS protection, backed by transparent capacity claims. |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | Premium China-optimized connectivity from Asia-Pacific data centers, featuring low latency, high bandwidth, and Tbps-scale local DDoS mitigation. Join the [Telegram community](https://t.me/SharonNetwork) to participate in charitable initiatives and community giveaways. |
|
||||
|
||||
## 许可证
|
||||
[MIT License](LICENSE)
|
||||
## Contributors
|
||||
|
||||
## 截图
|
||||
Thanks to everyone who has contributed code, themes, plugins, documentation, translations, bug reports, or feedback to Komari.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari contributors" width="600"></a>
|
||||
|
||||
## Support the Project
|
||||
|
||||
## 碎碎念
|
||||
|
||||
### 我为什么做Komari?
|
||||
|
||||
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
|
||||
|
||||
> @古xx斯
|
||||
> 哪吒?
|
||||
> > 数据至少保存15天
|
||||
|
||||
> @sxxu
|
||||
> 怕不是做梦的预算
|
||||
|
||||
> @Vxxn
|
||||
> prometheus加grafana
|
||||
> > 部署太麻烦
|
||||
|
||||
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
|
||||
If Komari has been useful to you, consider buying me a coffee. Thank you for your support!
|
||||
|
||||
| WeChat Pay | TRON Network |
|
||||
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
|  |  |
|
||||
@@ -0,0 +1,66 @@
|
||||
# Komari
|
||||
|
||||

|
||||
[](https://deepwiki.com/komari-monitor/komari)
|
||||
|
||||

|
||||
|
||||
[English](./README.md) | [简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为,用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
|
||||
[文档](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **实时监控**: 秒级实时数据展示。
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
- **极强的可扩展性**: 支持自定义主题和插件。
|
||||
|
||||
## 快速开始
|
||||
|
||||
| 平台 | 介绍 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | 秒级部署网站、数据库及数百款热门 App,并采用按小时灵活计费。 |
|
||||
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel Appstore" width="180"></a> | 现代化、开源的Linux 服务器运维管理面板,提供网站、数据库、容器、文件、备份、安全与AI 管理能力,支持应用商店一键部署。 |
|
||||
|
||||
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
|
||||
|
||||
## 截图
|
||||
|
||||
| 页面 | 截图 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 主页仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="主页仪表盘"> |
|
||||
| 后台仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="后台仪表盘"> |
|
||||
| 历史图表 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8.webp" width="800" alt="历史图表"> |
|
||||
| 网页终端 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="网页终端"> |
|
||||
| 主题可自定义 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89.webp" width="800" alt="主题可自定义"> |
|
||||
| 主题市场 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA.webp" width="800" alt="主题市场"> |
|
||||
|
||||
## 赞助商
|
||||
|
||||
有意赞助 Komari?请通过 [电子邮箱](mailto:komari@akz.moe) 或 [Telegram](https://t.me/mamomoe) 联系开发者。
|
||||
|
||||
| 赞助商 | 描述 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢所有为 Komari 贡献代码、主题、插件、文档、翻译、问题报告或反馈的朋友。
|
||||
|
||||
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari 贡献者" width="600"></a>
|
||||
|
||||
## 支持项目
|
||||
|
||||
如果 Komari 对你有所帮助,欢迎请作者喝一杯奶茶。感谢你的支持!
|
||||
|
||||
| 微信赞赏码 | TRON Network |
|
||||
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
|  |  |
|
||||
@@ -1,35 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Komari is a single user system
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
|
||||
// 将 session 和 用户 UUID 传递到后续处理器
|
||||
c.Set("session", session)
|
||||
c.Set("uuid", uuid)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
Records map[string][]common.Report = make(map[string][]common.Report)
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
}
|
||||
|
||||
var TerminalSessionsMutex = &sync.Mutex{}
|
||||
var TerminalSessions = make(map[string]*TerminalSession)
|
||||
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute * 1).Unix()
|
||||
record := []models.Record{}
|
||||
// 遍历所有的客户端记录
|
||||
for uuid, reports := range Records {
|
||||
// 删除一分钟前的记录
|
||||
filtered := reports[:0]
|
||||
for _, r := range reports {
|
||||
if r.UpdatedAt.Unix() >= lastMinute {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
Records[uuid] = filtered
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered)
|
||||
|
||||
record = append(record, r)
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Record{}).Where("client = ?", uuid).Create(record).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Respond sends a standardized JSON response.
|
||||
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// RespondSuccess sends a success response with data.
|
||||
func RespondSuccess(c *gin.Context, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", "", data)
|
||||
}
|
||||
|
||||
// RespondSuccessMessage sends a success response with message and data.
|
||||
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", message, data)
|
||||
}
|
||||
|
||||
// RespondError sends an error response with message.
|
||||
func RespondError(c *gin.Context, httpStatus int, message string) {
|
||||
Respond(c, httpStatus, "error", message, nil)
|
||||
}
|
||||
func GetVersion(c *gin.Context) {
|
||||
RespondSuccess(c, gin.H{
|
||||
"version": utils.CurrentVersion,
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
|
||||
func TokenAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var token string
|
||||
|
||||
// Step 1: Check query parameter for token
|
||||
token = c.Query("token")
|
||||
|
||||
// Step 2: If no token in query, check request body for non-GET requests
|
||||
if token == "" && c.Request.Method != http.MethodGet {
|
||||
// Read the body
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the body for downstream handlers
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Try to extract token from JSON body
|
||||
var bodyMap map[string]interface{}
|
||||
if len(bodyBytes) > 0 {
|
||||
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
|
||||
if tokenVal, exists := bodyMap["token"]; exists {
|
||||
if str, ok := tokenVal.(string); ok && str != "" {
|
||||
token = str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := checkTokenExists(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func checkTokenExists(token string) (bool, error) {
|
||||
_, err := clients.GetClientUUIDByToken(token)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
)
|
||||
|
||||
func AddClient(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||
uuid, token, err := clients.CreateClient()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
|
||||
return
|
||||
}
|
||||
uuid, token, err := clients.CreateClientWithName(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
|
||||
}
|
||||
|
||||
func EditClient(c *gin.Context) {
|
||||
var req = make(map[string]interface{})
|
||||
uuid := c.Param("uuid")
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
|
||||
return
|
||||
}
|
||||
req["uuid"] = uuid
|
||||
err := clients.SaveClient(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func RemoveClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
err := clients.DeleteClient(uuid)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to delete client" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func ClearRecord(c *gin.Context) {
|
||||
if err := records.DeleteAll(); err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Failed to delete Record" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func GetClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func ListClients(c *gin.Context) {
|
||||
cls, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cls)
|
||||
}
|
||||
|
||||
func GetClientToken(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := clients.GetClientTokenByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func OrderWeight(c *gin.Context) {
|
||||
var req = make(map[string]int)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
for uuid, weight := range req {
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
// 接受数据类型:
|
||||
// - command: string
|
||||
// - clients: []string (客户端 UUID 列表)
|
||||
func Exec(c *gin.Context) {
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for uuid := range ws.ConnectedClients {
|
||||
if contain(req.Clients, uuid) {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
api.RespondError(c, 400, "No clients connected")
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, uuid := range onlineClients {
|
||||
var send struct {
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command"`
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
send.Message = "exec"
|
||||
send.Command = req.Command
|
||||
send.TaskId = taskId
|
||||
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.ConnectedClients[uuid]
|
||||
if client != nil {
|
||||
client.WriteMessage(websocket.TextMessage, payload)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client connection is null: "+uuid)
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
})
|
||||
}
|
||||
func contain(clients []string, uuid string) bool {
|
||||
for _, client := range clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetLogs(c *gin.Context) {
|
||||
limit := c.Query("limit")
|
||||
if limit == "" {
|
||||
limit = "100" // Default to 100 logs if not specified
|
||||
}
|
||||
page := c.Query("page")
|
||||
if page == "" {
|
||||
page = "1" // Default to page 1 if not specified
|
||||
}
|
||||
// Convert limit and page to integers
|
||||
// If conversion fails, return an error
|
||||
limitInt, err := strconv.Atoi(limit)
|
||||
if err != nil || limitInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid limit: "+limit)
|
||||
return
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil || pageInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid page: "+page)
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var logs []models.Log
|
||||
// 添加分页:计算偏移量并限制数量
|
||||
offset := (pageInt - 1) * limitInt
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSessions(c *gin.Context) {
|
||||
|
||||
ss, err := accounts.GetAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
current, _ := c.Cookie("session_token")
|
||||
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
|
||||
}
|
||||
|
||||
func DeleteSession(c *gin.Context) {
|
||||
var req struct {
|
||||
Session string `json:"session" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := accounts.DeleteSession(req.Session)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteAllSession(c *gin.Context) {
|
||||
|
||||
err := accounts.DeleteAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSettings 获取自定义配置
|
||||
func GetSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
//override
|
||||
cst = models.Config{Sitename: "Komari"}
|
||||
cst.ID = 1
|
||||
config.Save(cst)
|
||||
api.RespondSuccess(c, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
|
||||
// EditSettings 更新自定义配置
|
||||
func EditSettings(c *gin.Context) {
|
||||
cfg := make(map[string]interface{})
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
ignoredKeys := []string{"id", "updated_at"}
|
||||
if contains(ignoredKeys, key) {
|
||||
continue
|
||||
}
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
message = message[:len(message)-2]
|
||||
}
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, v := range slice {
|
||||
if v == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetTasks(c *gin.Context) {
|
||||
dbTasks, err := tasks.GetAllTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
var responseTasks []gin.H
|
||||
for _, t := range dbTasks {
|
||||
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
responseTasks = append(responseTasks, gin.H{
|
||||
"task_id": t.TaskId,
|
||||
"clients": t.Clients,
|
||||
"command": t.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, responseTasks)
|
||||
}
|
||||
|
||||
func GetTaskById(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
task, err := tasks.GetTaskByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
api.RespondError(c, 404, "Task not found")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": task.TaskId,
|
||||
"clients": task.Clients,
|
||||
"command": task.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTasksByClientId(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
tasks, err := tasks.GetTasksByClientId(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, tasks)
|
||||
}
|
||||
|
||||
func GetSpecificTaskResult(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
clientId := c.Param("uuid")
|
||||
if taskId == "" || clientId == "" {
|
||||
api.RespondError(c, 400, "Task ID and Client ID are required")
|
||||
return
|
||||
}
|
||||
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
api.RespondError(c, 404, "No result found for this task and client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, result)
|
||||
}
|
||||
|
||||
// Param: task_id
|
||||
func GetTaskResultsByTaskId(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No results found for this task")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
|
||||
func GetAllTaskResultByUUID(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetAllTasksResultByUUID(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/telegram"
|
||||
)
|
||||
|
||||
func TestTelegram(c *gin.Context) {
|
||||
message := "Komari test message."
|
||||
err := telegram.SendTextMessage(message)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func TestGeoIp(c *gin.Context) {
|
||||
ip := c.Query("ip")
|
||||
if ip == "" {
|
||||
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
|
||||
ip = cfIP
|
||||
} else {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, GeoIpRecord)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
)
|
||||
|
||||
func UploadFavicon(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteFavicon(c *gin.Context) {
|
||||
if err := os.Remove("./data/favicon.ico"); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "Favicon not found")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.UpdateGeoIpDatabase(); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
)
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Name *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
SsoType *string `json:"sso_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Password == nil && req.Name == nil {
|
||||
api.RespondError(c, 400, "At least one field (username or password) must be provided")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && len(*req.Name) < 3 {
|
||||
api.RespondError(c, 400, "Username must be at least 3 characters long")
|
||||
return
|
||||
}
|
||||
if req.Password != nil && len(*req.Password) < 6 {
|
||||
api.RespondError(c, 400, "Password must be at least 6 characters long")
|
||||
return
|
||||
}
|
||||
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update user: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func UploadReport(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Println("Failed to read request body:", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
// Save report to database
|
||||
var report common.Report
|
||||
err = json.Unmarshal(bodyBytes, &report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(report.UUID, report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
|
||||
return
|
||||
}
|
||||
// Update report with method and token
|
||||
|
||||
ws.LatestReport[report.UUID] = &report
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func WebSocketReport(c *gin.Context) {
|
||||
// 升级ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("Error reading message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次数据拿token
|
||||
data := map[string]interface{}{}
|
||||
err = json.Unmarshal(message, &data)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
// it should ok,token was verfied in the middleware
|
||||
token := ""
|
||||
var errMsg string
|
||||
|
||||
// 优先检查查询参数中的 token
|
||||
token = c.Query("token")
|
||||
|
||||
// 如果 token 为空,返回错误
|
||||
if token == "" {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许一个客户端的连接
|
||||
if _, exists := ws.ConnectedClients[uuid]; exists {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
|
||||
return
|
||||
}
|
||||
|
||||
ws.ConnectedClients[uuid] = conn
|
||||
defer func() {
|
||||
delete(ws.ConnectedClients, uuid)
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
|
||||
}
|
||||
|
||||
ws.LatestReport[uuid] = &report
|
||||
}
|
||||
}
|
||||
|
||||
func SaveClientReport(uuid string, report common.Report) error {
|
||||
if api.Records[uuid] == nil {
|
||||
api.Records[uuid] = []common.Report{}
|
||||
}
|
||||
if report.CPU.Usage < 0.01 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
api.Records[uuid] = append(api.Records[uuid], report)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func TaskResult(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
clientId, _ := clients.GetClientUUIDByToken(token)
|
||||
if clientId == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TaskId string `json:"task_id" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
FinishedAt time.Time `json:"finished_at" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UploadBasicInfo(c *gin.Context) {
|
||||
var cbi = map[string]interface{}{}
|
||||
if err := c.ShouldBindJSON(&cbi); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if uuid == "" || err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := clients.SaveClientInfo(cbi); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": err})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, Records[uuid])
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFa string `json:"2fa_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := config.Get()
|
||||
if conf.DisablePasswordLogin {
|
||||
RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
}
|
||||
func Logout(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
accounts.DeleteSession(session)
|
||||
c.SetCookie("session_token", "", -1, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMe(c *gin.Context) {
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
user, err := accounts.GetUserByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMe(t *testing.T) {
|
||||
// 设置测试模式
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 创建测试用户
|
||||
user, err := accounts.CreateAccount("testuser", "password")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
uuid := user.UUID
|
||||
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionToken string
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "有效的会话",
|
||||
sessionToken: sessionToken,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "testuser",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的会话",
|
||||
sessionToken: "invalid_token",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无会话",
|
||||
sessionToken: "",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建测试路由
|
||||
router := gin.New()
|
||||
router.GET("/me", GetMe)
|
||||
|
||||
// 创建测试请求
|
||||
req, _ := http.NewRequest("GET", "/me", nil)
|
||||
if tt.sessionToken != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: tt.sessionToken,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建响应记录器
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// 执行请求
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 断言状态码
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
// 解析响应体
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 断言响应体
|
||||
assert.Equal(t, tt.expectedBody, response)
|
||||
})
|
||||
}
|
||||
|
||||
// 清除测试数据
|
||||
accounts.DeleteAccountByUsername("testuser")
|
||||
accounts.DeleteAllSessions()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
)
|
||||
|
||||
func GetNodesInformation(c *gin.Context) {
|
||||
clientList, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count := len(clientList)
|
||||
// 公开信息不展示IP地址,私有备注不展示
|
||||
for i := 0; i < count; i++ {
|
||||
clientList[i].IPv4 = ""
|
||||
clientList[i].IPv6 = ""
|
||||
clientList[i].Remark = ""
|
||||
clientList[i].Version = ""
|
||||
clientList[i].Token = ""
|
||||
}
|
||||
|
||||
RespondSuccess(c, clientList)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
if !cfg.OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state := oauth.GetOAuthUrl()
|
||||
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
c.Redirect(302, authURL)
|
||||
}
|
||||
|
||||
// /api/oauth_callback
|
||||
func OAuthCallback(c *gin.Context) {
|
||||
// 验证state防止CSRF攻击
|
||||
state, _ := c.Cookie("oauth_state")
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
|
||||
if state == "" || state != c.Query("state") {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取code
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
|
||||
return
|
||||
}
|
||||
|
||||
githubUser, err := oauth.OAuthCallback(code, state)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用GitHub ID作为SSO ID
|
||||
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
|
||||
|
||||
// 如果cookie中有binding_external_account,说明是绑定外部账号
|
||||
// 否则是登录
|
||||
uuid, _ := c.Cookie("binding_external_account")
|
||||
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
|
||||
if uuid != "" {
|
||||
// 绑定外部账号
|
||||
session, _ := c.Cookie("session_token")
|
||||
user, err := accounts.GetUserBySession(session)
|
||||
if err != nil || user.UUID != uuid {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
|
||||
return
|
||||
}
|
||||
err = accounts.BindingExternalAccount(user.UUID, sso_id)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
|
||||
return
|
||||
}
|
||||
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
|
||||
c.Redirect(302, "/manage")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取用户
|
||||
user, err := accounts.GetUserBySSO(sso_id)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "error",
|
||||
"message": "please log in and bind your GitHub account first.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置cookie并返回
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
c.Redirect(302, "/manage")
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
if err != nil {
|
||||
RespondError(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Return public settings including CORS
|
||||
RespondSuccess(c, gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
"description": cst.Description,
|
||||
"custom_head": cst.CustomHead,
|
||||
"custom_body": cst.CustomBody,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
})
|
||||
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func RequestTerminal(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
_, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 建立ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 新建一个终端连接
|
||||
id := utils.GenerateRandomString(32)
|
||||
session := &TerminalSession{
|
||||
UserUUID: user_uuid.(string),
|
||||
UUID: uuid,
|
||||
Browser: conn,
|
||||
Agent: nil,
|
||||
}
|
||||
|
||||
TerminalSessionsMutex.Lock()
|
||||
TerminalSessions[id] = session
|
||||
TerminalSessionsMutex.Unlock()
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
log.Println("Terminal connection closed:", code, text)
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
// 通知 Agent 关闭终端连接
|
||||
if session.Agent != nil {
|
||||
session.Agent.Close()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ws.ConnectedClients[uuid] == nil {
|
||||
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
err = ws.ConnectedClients[uuid].WriteJSON(gin.H{
|
||||
"message": "terminal",
|
||||
"request_id": id,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
|
||||
// 如果没有连接上,则关闭连接
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Agent == nil {
|
||||
if session.Browser != nil {
|
||||
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
|
||||
session.Browser.Close()
|
||||
}
|
||||
conn.Close()
|
||||
delete(TerminalSessions, id)
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
}
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
session, exists := TerminalSessions[id]
|
||||
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
|
||||
established_time := time.Now()
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
messageType, data, err := session.Browser.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if messageType == websocket.TextMessage {
|
||||
if session.Agent != nil && string(data[0:1]) == "{" {
|
||||
err = session.Agent.WriteMessage(websocket.TextMessage, data)
|
||||
} else if session.Agent != nil {
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
} else if session.Agent != nil {
|
||||
// 二进制消息,原样传递
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
_, data, err := session.Agent.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
if session.Browser != nil {
|
||||
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待错误或主动关闭
|
||||
<-errChan
|
||||
// 关闭连接
|
||||
if session.Agent != nil {
|
||||
session.Agent.Close()
|
||||
}
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
disconnect_time := time.Now()
|
||||
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
}
|
||||
+9
-6
@@ -5,11 +5,13 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
Username string
|
||||
//Username string
|
||||
NewPassword string
|
||||
)
|
||||
|
||||
@@ -17,7 +19,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
Use: "chpasswd",
|
||||
Short: "Force change password",
|
||||
Long: `Force change password`,
|
||||
Example: `komari chpasswd -u <username> -p <password>`,
|
||||
Example: `komari chpasswd -p <password>`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if NewPassword == "" {
|
||||
cmd.Help()
|
||||
@@ -27,9 +29,10 @@ var ChpasswdCmd = &cobra.Command{
|
||||
cmd.Println("Database file does not exist.")
|
||||
return
|
||||
}
|
||||
|
||||
cmd.Println("Changing password for user:", Username)
|
||||
if err := accounts.ForceResetPassword(Username, NewPassword); err != nil {
|
||||
user := &models.User{}
|
||||
dbcore.GetDBInstance().Model(&models.User{}).First(user)
|
||||
cmd.Println("Changing password for user:", user.Username)
|
||||
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
@@ -45,7 +48,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
//ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&NewPassword, "password", "p", "", "New password")
|
||||
RootCmd.AddCommand(ChpasswdCmd)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var Disable2FA = &cobra.Command{
|
||||
Use: "disable-2fa",
|
||||
Short: "Force disable 2FA",
|
||||
Long: `Force disable 2FA`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.User{}).Where("two_factor != ?", "").
|
||||
Update("two_factor", "").Error
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd.Println("2FA has been disabled.")
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(Disable2FA)
|
||||
}
|
||||
+28
-6
@@ -1,14 +1,36 @@
|
||||
package flags
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DatabaseTypeSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite, mysql
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // MySQL/其他数据库主机地址
|
||||
DatabasePort string // MySQL/其他数据库端口
|
||||
DatabaseUser string // MySQL/其他数据库用户名
|
||||
DatabasePass string // MySQL/其他数据库密码
|
||||
DatabaseName string // MySQL/其他数据库名称
|
||||
|
||||
Listen string
|
||||
)
|
||||
|
||||
func NormalizeDatabaseType(databaseType string) string {
|
||||
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
|
||||
if databaseType == "" {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
return databaseType
|
||||
}
|
||||
|
||||
func ApplyDatabaseTypeNormalization() string {
|
||||
DatabaseType = NormalizeDatabaseType(DatabaseType)
|
||||
return DatabaseType
|
||||
}
|
||||
|
||||
func IsSQLite() bool {
|
||||
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
|
||||
}
|
||||
|
||||
func SupportedDatabaseTypes() string {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package flags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDatabaseType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": DatabaseTypeSQLite,
|
||||
"sqlite": DatabaseTypeSQLite,
|
||||
" SQLite ": DatabaseTypeSQLite,
|
||||
"SQLITE": DatabaseTypeSQLite,
|
||||
"postgres": "postgres",
|
||||
" postgres": "postgres",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeDatabaseType(input); got != want {
|
||||
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.Config{}).Where("id = ?", 1).
|
||||
Update("disable_password_login", false).Error
|
||||
})
|
||||
if err != nil {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+3
-20
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
@@ -17,17 +17,6 @@ func getEnv(key, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// 从环境变量获取默认值
|
||||
var (
|
||||
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "Komari",
|
||||
Short: "Komari is a simple server monitoring tool",
|
||||
@@ -47,12 +36,6 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", "sqlite", "Database type (sqlite)")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "SQLite database file path")
|
||||
}
|
||||
+88
-226
@@ -1,260 +1,122 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
log_api "github.com/komari-monitor/komari/api/admin/log"
|
||||
"github.com/komari-monitor/komari/api/admin/test"
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
appserver "github.com/komari-monitor/komari/internal/server"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
DynamicCorsEnabled bool = false
|
||||
)
|
||||
|
||||
var ServerCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the server",
|
||||
Long: `Start the server`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
InitDatabase()
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 动态 CORS 中间件
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "43200") // 12 hours
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
|
||||
r.POST("/api/login", api.Login)
|
||||
r.GET("/api/me", api.GetMe)
|
||||
r.GET("/api/clients", ws.GetClients)
|
||||
r.GET("/api/nodes", api.GetNodesInformation)
|
||||
r.GET("/api/public", api.GetPublicSettings)
|
||||
r.GET("/api/oauth", api.OAuth)
|
||||
r.GET("/api/oauth_callback", api.OAuthCallback)
|
||||
r.GET("/api/logout", api.Logout)
|
||||
r.GET("/api/version", api.GetVersion)
|
||||
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
|
||||
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", client.EstablishConnection)
|
||||
tokenAuthrized.POST("/task/result", client.TaskResult)
|
||||
}
|
||||
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
// test
|
||||
testGroup := adminAuthrized.Group("/test")
|
||||
{
|
||||
testGroup.GET("/telegram", test.TestTelegram)
|
||||
testGroup.GET("/geoip", test.TestGeoIp)
|
||||
}
|
||||
// update
|
||||
updateGroup := adminAuthrized.Group("/update")
|
||||
{
|
||||
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
|
||||
updateGroup.POST("/user", update.UpdateUser)
|
||||
updateGroup.PUT("/favicon", update.UploadFavicon)
|
||||
updateGroup.POST("/favicon", update.DeleteFavicon)
|
||||
}
|
||||
// tasks
|
||||
taskGroup := adminAuthrized.Group("/task")
|
||||
{
|
||||
taskGroup.GET("/all", admin.GetTasks)
|
||||
taskGroup.POST("/exec", admin.Exec)
|
||||
taskGroup.GET("/:task_id", admin.GetTaskById)
|
||||
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
|
||||
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
|
||||
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
|
||||
}
|
||||
// settings
|
||||
adminAuthrized.GET("/settings", admin.GetSettings)
|
||||
adminAuthrized.POST("/settings", admin.EditSettings)
|
||||
// clients
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
{
|
||||
clientGroup.POST("/add", admin.AddClient)
|
||||
clientGroup.GET("/list", admin.ListClients)
|
||||
clientGroup.GET("/:uuid", admin.GetClient)
|
||||
clientGroup.POST("/:uuid/edit", admin.EditClient)
|
||||
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
|
||||
clientGroup.GET("/:uuid/token", admin.GetClientToken)
|
||||
clientGroup.POST("/order", admin.OrderWeight)
|
||||
// client terminal
|
||||
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
// 静态文件服务
|
||||
public.UpdateIndex(conf)
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
public.UpdateIndex(event.New)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
OnFatal(err)
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
OnShutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
RunServer()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 从环境变量获取监听地址
|
||||
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
func InitDatabase() {
|
||||
// // 打印数据库类型和连接信息
|
||||
// if flags.DatabaseType == "mysql" {
|
||||
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
|
||||
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
|
||||
// } else {
|
||||
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
|
||||
// }
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
// RunServer 按显式的生命周期阶段启动服务端。
|
||||
//
|
||||
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
|
||||
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
|
||||
func RunServer() {
|
||||
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
|
||||
}
|
||||
|
||||
installRequired, err := app.InstallRequired()
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", err)
|
||||
}
|
||||
if installRequired {
|
||||
completed, err := app.RunInstallGuide()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
}
|
||||
}
|
||||
|
||||
func DoScheduledWork() {
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
ticker1 := time.NewTicker(60 * time.Second)
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
logOperation.RemoveOldLogs()
|
||||
case <-ticker1.C:
|
||||
api.SaveClientReportToDB()
|
||||
requirement, err := app.DatabaseMigrationRequired()
|
||||
if err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "database-migration-detection-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !requirement.Required() {
|
||||
break
|
||||
}
|
||||
completed, err := app.RunDatabaseMigration(requirement)
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-database-migration", err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
|
||||
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
|
||||
if err := app.ConnectMetricStoreWithRetry(); err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
logOperation.Log("", "", "server is shutting down", "info")
|
||||
}
|
||||
// 其余初始化阶段:任一步失败都不应继续对外服务。
|
||||
type stage struct {
|
||||
name string
|
||||
fn func() error
|
||||
}
|
||||
stages := []stage{
|
||||
{"init-stores", app.InitStores},
|
||||
{"init-providers", app.InitProviders},
|
||||
{"start-background", app.StartBackground},
|
||||
{"build-router", app.BuildRouter},
|
||||
}
|
||||
for _, s := range stages {
|
||||
if err := s.fn(); err != nil {
|
||||
// 已登记的资源尽力回收,再退出。
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
if err := app.Run(); err != nil {
|
||||
if errors.Is(err, appserver.ErrRestartRequested) {
|
||||
logger.Infof("server", "Service stopped for restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Fatalf("server", "server exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
CPU bool `json:"cpu" gorm:"default:true"`
|
||||
GPU bool `json:"gpu" gorm:"default:true"`
|
||||
RAM bool `json:"ram" gorm:"default:true"`
|
||||
SWAP bool `json:"swap" gorm:"default:true"`
|
||||
LOAD bool `json:"load" gorm:"default:true"`
|
||||
UPTIME bool `json:"uptime" gorm:"default:true"`
|
||||
TEMP bool `json:"temp" gorm:"default:true"`
|
||||
OS bool `json:"os" gorm:"default:true"`
|
||||
DISK bool `json:"disk" gorm:"default:true"`
|
||||
NET bool `json:"net" gorm:"default:true"`
|
||||
PROCESS bool `json:"process" gorm:"default:true"`
|
||||
Connections bool `json:"connections" gorm:"default:true"`
|
||||
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClientInfo stores static client information
|
||||
// Deprecated: Use models.Client instead.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type IPAddress struct {
|
||||
Ipv4 string `json:"ipv4"`
|
||||
Ipv6 string `json:"ipv6"`
|
||||
}
|
||||
type Report struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
CPU CPUReport `json:"cpu"`
|
||||
Ram RamReport `json:"ram"`
|
||||
Swap RamReport `json:"swap"`
|
||||
Load LoadReport `json:"load"`
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
Method string `json:"method,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type RamReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type LoadReport struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type DiskReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type NetworkReport struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
TotalUp int64 `json:"totalUp"`
|
||||
TotalDown int64 `json:"totalDown"`
|
||||
}
|
||||
|
||||
type ConnectionsReport struct {
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const constantSalt = "06Wm4Jv1Hkxx"
|
||||
@@ -56,7 +54,10 @@ func hashPasswd(passwd string) string {
|
||||
}
|
||||
|
||||
func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
|
||||
}
|
||||
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
user = models.User{
|
||||
UUID: uuid.New().String(),
|
||||
@@ -71,7 +72,10 @@ func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
}
|
||||
|
||||
func DeleteAccountByUsername(username string) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
|
||||
}
|
||||
|
||||
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
|
||||
err = db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,39 +83,6 @@ func DeleteAccountByUsername(username string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建默认管理员账户,使用环境变量 ADMIN_USERNAME 作为用户名,环境变量 ADMIN_PASSWORD 作为密码
|
||||
func CreateDefaultAdminAccount() (username, passwd string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
username = os.Getenv("ADMIN_USERNAME")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
passwd = os.Getenv("ADMIN_PASSWORD")
|
||||
if passwd == "" {
|
||||
passwd = utils.GeneratePassword()
|
||||
}
|
||||
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
SSOID: "",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, passwd, nil
|
||||
}
|
||||
|
||||
func GetUserByUUID(uuid string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&user).Error
|
||||
@@ -171,7 +142,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
if sso_type != nil {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,11 +2,17 @@ package accounts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// GetAllSessions 获取所有会话
|
||||
@@ -27,12 +33,29 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: time.Now().Add(time.Duration(expires) * time.Second),
|
||||
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: time.Now(),
|
||||
LatestOnline: time.Now().UTC(),
|
||||
}
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
if LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
_ = messageSender.SendNotification(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now().UTC(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
@@ -50,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().After(sessionRecord.Expires) {
|
||||
if time.Now().UTC().After(sessionRecord.Expires) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -88,25 +111,20 @@ func DeleteAllSessions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateLatestOnline(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
|
||||
}
|
||||
|
||||
func UpdateLatestUserAgent(session, userAgent string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
|
||||
}
|
||||
func UpdateLatestIp(session, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
|
||||
}
|
||||
|
||||
func UpdateLatest(session, useragent, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
|
||||
"latest_online": time.Now(),
|
||||
"latest_online": time.Now().UTC(),
|
||||
"latest_user_agent": useragent,
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package logOperation
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"log"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
@@ -9,23 +9,28 @@ import (
|
||||
)
|
||||
|
||||
func Log(ip, uuid, message, msgType string) {
|
||||
now := time.Now()
|
||||
db := dbcore.GetDBInstance()
|
||||
logEntry := &models.Log{
|
||||
IP: ip,
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: now,
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
db.Create(logEntry)
|
||||
if err := db.Create(logEntry).Error; err != nil {
|
||||
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func EventLog(eventType, message string) {
|
||||
Log("", "", message, eventType)
|
||||
}
|
||||
|
||||
// Delete logs older than 30 days
|
||||
func RemoveOldLogs() {
|
||||
db := dbcore.GetDBInstance()
|
||||
threshold := time.Now().AddDate(0, 0, -30)
|
||||
threshold := time.Now().UTC().AddDate(0, 0, -30)
|
||||
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
|
||||
log.Println("Failed to remove old logs:", err)
|
||||
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
|
||||
}
|
||||
}
|
||||
+120
-146
@@ -1,29 +1,20 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
|
||||
func DeleteClientConfig(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
@@ -33,79 +24,6 @@ func DeleteClient(clientUuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
|
||||
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if cbi.Name != "" {
|
||||
updates["name"] = cbi.Name
|
||||
}
|
||||
if cbi.CpuName != "" {
|
||||
updates["cpu_name"] = cbi.CpuName
|
||||
}
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores != 0 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
updates["os"] = cbi.OS
|
||||
}
|
||||
if cbi.GpuName != "" {
|
||||
updates["gpu_name"] = cbi.GpuName
|
||||
}
|
||||
if cbi.IPv4 != "" {
|
||||
updates["ipv4"] = cbi.IPv4
|
||||
}
|
||||
if cbi.IPv6 != "" {
|
||||
updates["ipv6"] = cbi.IPv6
|
||||
}
|
||||
if cbi.Region != "" {
|
||||
updates["region"] = cbi.Region
|
||||
}
|
||||
if cbi.Remark != "" {
|
||||
updates["remark"] = cbi.Remark
|
||||
}
|
||||
updates["mem_total"] = cbi.MemTotal
|
||||
updates["swap_total"] = cbi.SwapTotal
|
||||
updates["disk_total"] = cbi.DiskTotal
|
||||
updates["version"] = cbi.Version
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
// 转换为更新Client表
|
||||
client := models.Client{
|
||||
UUID: cbi.UUID,
|
||||
}
|
||||
|
||||
err := db.Model(&client).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(map[string]interface{}{
|
||||
"uuid": cbi.UUID,
|
||||
"name": cbi.Name,
|
||||
"cpu_name": cbi.CpuName,
|
||||
"arch": cbi.Arch,
|
||||
"cpu_cores": cbi.CpuCores,
|
||||
"os": cbi.OS,
|
||||
"gpu_name": cbi.GpuName,
|
||||
"ipv4": cbi.IPv4,
|
||||
"ipv6": cbi.IPv6,
|
||||
"region": cbi.Region,
|
||||
"remark": cbi.Remark,
|
||||
"mem_total": cbi.MemTotal,
|
||||
"swap_total": cbi.SwapTotal,
|
||||
"disk_total": cbi.DiskTotal,
|
||||
"version": cbi.Version,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
@@ -118,52 +36,85 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新客户端设置
|
||||
func UpdateClientConfig(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Save(&config).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientName(clientUUID, clientName string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// UpdateClientByUUID 更新指定 UUID 的客户端配置
|
||||
|
||||
func UpdateClientByUUID(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int8:
|
||||
return float64(typed), true
|
||||
case int16:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case uint:
|
||||
return float64(typed), true
|
||||
case uint8:
|
||||
return float64(typed), true
|
||||
case uint16:
|
||||
return float64(typed), true
|
||||
case uint32:
|
||||
return float64(typed), true
|
||||
case uint64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
checkOptionalInt := func(name, key string, maxValue float64) error {
|
||||
value, exists := update[key]
|
||||
if !exists || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
numericValue, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s must be a valid number", name)
|
||||
}
|
||||
if numericValue < 0 || numericValue > maxValue {
|
||||
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
func EditClientToken(clientUUID, token string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verify(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,14 +131,17 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -202,14 +156,17 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -234,19 +191,6 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
|
||||
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
|
||||
}
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -278,7 +222,37 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, exists := updates["expired_at"]; exists {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
updates["expired_at"] = nil
|
||||
case time.Time:
|
||||
updates["expired_at"] = typed.UTC()
|
||||
case *time.Time:
|
||||
if typed == nil {
|
||||
updates["expired_at"] = nil
|
||||
} else {
|
||||
updates["expired_at"] = typed.UTC()
|
||||
}
|
||||
case string:
|
||||
stamp, err := time.Parse(time.RFC3339Nano, typed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
|
||||
}
|
||||
updates["expired_at"] = stamp.UTC()
|
||||
default:
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
|
||||
+75
-107
@@ -1,34 +1,14 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"math"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
// Report 表示客户端报告数据
|
||||
// SaveReport 保存报告数据
|
||||
func SaveReport(uuid string, data map[string]interface{}) (err error) {
|
||||
|
||||
report, err := ParseReport(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -39,98 +19,86 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: time.Now(),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
SwapTotal: report.Swap.Total,
|
||||
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
|
||||
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
|
||||
Disk: report.Disk.Used,
|
||||
DiskTotal: report.Disk.Total,
|
||||
NetIn: report.Network.Down,
|
||||
NetOut: report.Network.Up,
|
||||
NetTotalUp: report.Network.TotalUp,
|
||||
NetTotalDown: report.Network.TotalDown,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
|
||||
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
|
||||
}
|
||||
|
||||
checkFloat64 := func(name string, val float64) error {
|
||||
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
|
||||
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// [float64] 防止数据溢出
|
||||
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkInt64 := func(name string, val int64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, val)
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("Process must be non-negative: %d", report.Process)
|
||||
}
|
||||
if report.Connections.TCP < 0 {
|
||||
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
|
||||
}
|
||||
if report.Connections.UDP < 0 {
|
||||
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// getString 从 map 中获取字符串
|
||||
func getString(data map[string]interface{}, key string) string {
|
||||
if val, ok := data[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt 从 map 中获取整数
|
||||
func getInt(data map[string]interface{}, key string) int {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getInt64 从 map 中获取 int64
|
||||
func getInt64(data map[string]interface{}, key string) int64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int64(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFloat 从 map 中获取 float64
|
||||
func getFloat(data map[string]interface{}, key string) float64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,64 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
cb.CreatedAt = time.Now().UTC()
|
||||
cb.UpdatedAt = cb.CreatedAt
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
// GetClipboardByID 根据ID获取剪贴板记录
|
||||
func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
var cb models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
delete(fields, "created_at")
|
||||
fields["updated_at"] = time.Now().UTC()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteClipboard 删除剪贴板记录
|
||||
func DeleteClipboard(id int) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if record exists first
|
||||
var cb models.Clipboard
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return err // Record not found or other error
|
||||
}
|
||||
return db.Delete(&cb).Error
|
||||
}
|
||||
|
||||
// DeleteClipboardBatch 批量删除剪贴板记录
|
||||
func DeleteClipboardBatch(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
|
||||
}
|
||||
|
||||
// ListClipboard 列出所有剪贴板记录
|
||||
func ListClipboard() ([]models.Clipboard, error) {
|
||||
var list []models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Get() (models.Config, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.Config
|
||||
if err := db.First(&config).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
config = models.Config{
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "mmdb",
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
log.Fatal("Failed to create default config:", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Save(cst models.Config) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = time.Now()
|
||||
// Do not update CreatedAt
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("sitename",
|
||||
"description",
|
||||
"allow_cors",
|
||||
"geo_ip_enabled",
|
||||
"geo_ip_provider",
|
||||
"o_auth_client_id",
|
||||
"o_auth_client_secret",
|
||||
"o_auth_enabled",
|
||||
"custom_head",
|
||||
"updated_at").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newConfig, _ := Get()
|
||||
publishEvent(oldConfig, newConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a configuration change event
|
||||
type ConfigEvent struct {
|
||||
Old models.Config
|
||||
New models.Config
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal models.Config) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
for _, sub := range subscribers {
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
+444
-149
@@ -1,183 +1,478 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"log"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/driver/mysql"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/internal/migrations"
|
||||
"github.com/komari-monitor/komari/internal/sqlitetune"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
log.Printf("Failed to read ClientInfo table: %v", err)
|
||||
return
|
||||
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
|
||||
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
|
||||
// 规范化排除路径为绝对路径
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
|
||||
for _, info := range clientInfos {
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
|
||||
continue
|
||||
out, err := os.Create(dstZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDir)
|
||||
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新Client记录
|
||||
client.Name = info.Name
|
||||
client.CpuName = info.CpuName
|
||||
client.Virtualization = info.Virtualization
|
||||
client.Arch = info.Arch
|
||||
client.CpuCores = info.CpuCores
|
||||
client.OS = info.OS
|
||||
client.GpuName = info.GpuName
|
||||
client.IPv4 = info.IPv4
|
||||
client.IPv6 = info.IPv6
|
||||
client.Region = info.Region
|
||||
client.Remark = info.Remark
|
||||
client.PublicRemark = info.PublicRemark
|
||||
client.MemTotal = info.MemTotal
|
||||
client.SwapTotal = info.SwapTotal
|
||||
client.DiskTotal = info.DiskTotal
|
||||
client.Version = info.Version
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = info.ExpiredAt
|
||||
// Save updated Client record
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
log.Printf("Failed to update Client record: %v", err)
|
||||
continue
|
||||
// 排除 backup.zip 本身
|
||||
if _, ok := normExclude[path]; ok {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 计算 zip 内相对路径
|
||||
rel, err := filepath.Rel(absSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 根目录跳过
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 替换为正斜杠
|
||||
zipName := filepath.ToSlash(rel)
|
||||
|
||||
// Backup and rename old table after migration
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
log.Printf("Failed to backup ClientInfo table: %v", err)
|
||||
return
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(zipName + "/")
|
||||
return err
|
||||
}
|
||||
// 普通文件
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(zipName)
|
||||
if err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, fh); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
fh.Close()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func MergeDatabase(db *gorm.DB) {
|
||||
if db.Migrator().HasTable("client_infos") {
|
||||
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
|
||||
mergeClientInfo(db)
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(absDir, e.Name())
|
||||
if _, ok := normExclude[full]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
|
||||
func unzipToDir(zipPath, dstDir string) error {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
absDst, _ := filepath.Abs(dstDir)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// 构造目标路径并做路径遍历保护
|
||||
cleanName := filepath.Clean(f.Name)
|
||||
targetPath := filepath.Join(absDst, cleanName)
|
||||
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
|
||||
return fmt.Errorf("illegal file path in zip: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
// 初始化数据库
|
||||
// 对于 SQLite:true 如果数据库文件存在,false 如果数据库文件不存在并被创建
|
||||
// 对于 MySQL/其他数据库:总是返回 true
|
||||
// func InitDatabase() bool {
|
||||
// // 默认使用 SQLite 如果未指定类型
|
||||
// if flags.DatabaseType == "" || flags.DatabaseType == "sqlite" {
|
||||
// if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
|
||||
// log.Printf("SQLite database file %q does not exist, creating...", flags.DatabaseFile)
|
||||
// dbDir := filepath.Dir(flags.DatabaseFile)
|
||||
// if dbDir != "" {
|
||||
// if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
// log.Fatalf("Failed to create database file directory %q: %v", dbDir, err)
|
||||
// }
|
||||
// }
|
||||
// file, err := os.Create(flags.DatabaseFile)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to create SQLite database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// if err := file.Close(); err != nil {
|
||||
// log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// return false
|
||||
// } else if err != nil {
|
||||
// log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// return true
|
||||
// } else if flags.DatabaseType == "mysql" {
|
||||
// // 对于 MySQL,我们不需要创建文件,只需检查连接信息是否有效
|
||||
// log.Printf("Using MySQL database: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// return true
|
||||
// } else {
|
||||
// log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
|
||||
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
|
||||
// 也避免额外的裸文件依赖。
|
||||
const SystemVersionKey = "system_version"
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
const (
|
||||
mainSQLiteBusyTimeout = 5 * time.Second
|
||||
mainSQLiteCacheSizeKB = 8 * 1024
|
||||
mainSQLiteWALAutoCheckpoint = 256
|
||||
mainSQLiteJournalSizeLimit = 1 << 20
|
||||
)
|
||||
|
||||
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
|
||||
var versionID string
|
||||
|
||||
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
|
||||
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
|
||||
// 之前采集。
|
||||
var dbFileExistedAtStartup bool
|
||||
|
||||
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
|
||||
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
|
||||
func SetVersionID(id string) {
|
||||
versionID = id
|
||||
}
|
||||
|
||||
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
|
||||
func resolveDatabaseFile() string {
|
||||
dbFile := flags.DatabaseFile
|
||||
if dbFile == "" {
|
||||
dbFile = "./data/komari.db"
|
||||
}
|
||||
return dbFile
|
||||
}
|
||||
|
||||
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
|
||||
// ./data/backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
|
||||
//
|
||||
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
|
||||
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
|
||||
//
|
||||
// 触发规则:
|
||||
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
|
||||
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
|
||||
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
|
||||
// - 配置中版本与当前不同:版本升级,备份。
|
||||
// - 配置中版本与当前一致:无需备份。
|
||||
//
|
||||
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
|
||||
func backupOnVersionUpgrade() {
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
if versionRecorded && prevVersion == versionID {
|
||||
return
|
||||
}
|
||||
|
||||
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
|
||||
if !versionRecorded && !dbFileExistedAtStartup {
|
||||
writeVersionMarker()
|
||||
return
|
||||
}
|
||||
|
||||
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
|
||||
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
|
||||
// 避免备份出的库缺少仍留在 -wal 中的写入。
|
||||
if instance != nil {
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
|
||||
return
|
||||
}
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("upgrade-%s.zip", tsName))
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
|
||||
return
|
||||
}
|
||||
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
|
||||
|
||||
writeVersionMarker()
|
||||
}
|
||||
|
||||
// writeVersionMarker 将当前 versionID 写入配置库。
|
||||
func writeVersionMarker() {
|
||||
if err := config.Set(SystemVersionKey, versionID); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQLiteDSN(databaseFile string) string {
|
||||
if databaseFile == "" {
|
||||
databaseFile = "./data/komari.db"
|
||||
}
|
||||
|
||||
params := fmt.Sprintf("_busy_timeout=%d&_txlock=immediate", mainSQLiteBusyTimeout.Milliseconds())
|
||||
separator := "?"
|
||||
if strings.Contains(databaseFile, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(databaseFile, "file:") {
|
||||
return databaseFile + separator + params
|
||||
}
|
||||
|
||||
if databaseFile == ":memory:" {
|
||||
return "file::memory:?cache=shared&" + params
|
||||
}
|
||||
|
||||
return "file:" + filepath.ToSlash(databaseFile) + separator + params
|
||||
}
|
||||
|
||||
func mainSQLiteOptions() sqlitetune.Options {
|
||||
return sqlitetune.Options{
|
||||
BusyTimeout: mainSQLiteBusyTimeout,
|
||||
CacheSizeKB: mainSQLiteCacheSizeKB,
|
||||
MMapSizeBytes: 0,
|
||||
TempStoreMemory: false,
|
||||
CacheSpill: true,
|
||||
WALAutoCheckpoint: mainSQLiteWALAutoCheckpoint,
|
||||
JournalSizeLimitBytes: mainSQLiteJournalSizeLimit,
|
||||
Synchronous: sqlitetune.SynchronousNormal,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
|
||||
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
|
||||
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
|
||||
func Initialize() error {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
//Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.DatabaseType {
|
||||
case "sqlite", "":
|
||||
// SQLite 连接
|
||||
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
|
||||
case "mysql":
|
||||
// MySQL 连接
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
flags.DatabaseUser,
|
||||
flags.DatabasePass,
|
||||
flags.DatabaseHost,
|
||||
flags.DatabasePort,
|
||||
flags.DatabaseName)
|
||||
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
}
|
||||
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
}
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
&models.Log{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create tables: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
initErr = doInitialize()
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetDBInstance 返回全局数据库实例。
|
||||
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
|
||||
// 需要错误处理的启动流程应优先调用 Initialize()。
|
||||
func GetDBInstance() *gorm.DB {
|
||||
if err := Initialize(); err != nil {
|
||||
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
// Close 关闭底层数据库连接,供关闭流程调用。
|
||||
func Close() error {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := instance.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func doInitialize() error {
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 将当前数据快照保存到 ./data/backup/,并保留已有归档。
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("pre-restore-%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除数据文件,但保留归档目录和待恢复的 backup.zip。
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}, backupDir: {}}); delErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
|
||||
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
|
||||
// gorm.Open 会创建空库。
|
||||
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
|
||||
dbFileExistedAtStartup = true
|
||||
}
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.NewGormLogger(),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// _txlock=immediate lets writes acquire their lock before reads can
|
||||
// turn into a lock-upgrade conflict. sqlitetune applies the remaining
|
||||
// per-connection PRAGMAs whenever database/sql opens a connection.
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
sqlDB, dbErr := sqlitetune.Open(dsn, mainSQLiteOptions())
|
||||
if dbErr != nil {
|
||||
return fmt.Errorf("open SQLite connection pool: %w", dbErr)
|
||||
}
|
||||
// SQLite has one writer. Keeping exactly one durable connection also
|
||||
// keeps connection-local cache and WAL limits stable for the main DB.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
sqlDB.SetConnMaxIdleTime(0)
|
||||
|
||||
instance, err = gorm.Open(sqlite.New(sqlite.Config{Conn: sqlDB}), logConfig)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
|
||||
}
|
||||
if err := instance.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
|
||||
logger.Errorf("dbcore", "Failed to checkpoint SQLite WAL at startup: %v", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
return fmt.Errorf("failed to run startup migrations: %w", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
|
||||
// 配置库就绪后、执行后续 AutoMigrate 之前:
|
||||
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
|
||||
backupOnVersionUpgrade()
|
||||
|
||||
// 自动迁移模型
|
||||
//
|
||||
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
|
||||
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
|
||||
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
|
||||
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
|
||||
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
|
||||
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
&models.PluginConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
); err != nil {
|
||||
logger.Errorf("dbcore", "Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
); err != nil {
|
||||
logger.Errorf("dbcore", "Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
var maintenanceMu sync.Mutex
|
||||
|
||||
// StorageSize returns the bytes occupied by the main SQLite database and its
|
||||
// WAL/SHM sidecar files.
|
||||
func StorageSize() (int64, error) {
|
||||
if !flags.IsSQLite() {
|
||||
return 0, errors.New("main database size is only available for SQLite")
|
||||
}
|
||||
return sqliteFileSetSize(resolveDatabaseFile())
|
||||
}
|
||||
|
||||
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
|
||||
func ReclaimSpace(ctx context.Context) error {
|
||||
if !flags.IsSQLite() {
|
||||
return errors.New("main database maintenance is only supported for SQLite")
|
||||
}
|
||||
|
||||
maintenanceMu.Lock()
|
||||
defer maintenanceMu.Unlock()
|
||||
|
||||
db, err := GetDBInstance().DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get main database connection: %w", err)
|
||||
}
|
||||
if err := checkpointSQLiteWAL(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
|
||||
return fmt.Errorf("vacuum main database: %w", err)
|
||||
}
|
||||
return checkpointSQLiteWAL(ctx, db)
|
||||
}
|
||||
|
||||
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
return fmt.Errorf("checkpoint main database WAL: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf(
|
||||
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
|
||||
logFrames,
|
||||
checkpointedFrames,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteFileSetSize(dsn string) (int64, error) {
|
||||
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
|
||||
if index := strings.IndexByte(path, '?'); index >= 0 {
|
||||
path = path[:index]
|
||||
}
|
||||
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
|
||||
return 0, errors.New("database is not backed by a local file")
|
||||
}
|
||||
|
||||
var total int64
|
||||
foundDatabase := false
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
info, err := os.Stat(path + suffix)
|
||||
switch {
|
||||
case err == nil:
|
||||
total += info.Size()
|
||||
if suffix == "" {
|
||||
foundDatabase = true
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
continue
|
||||
default:
|
||||
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
|
||||
}
|
||||
}
|
||||
if !foundDatabase {
|
||||
return 0, fmt.Errorf("database file %q does not exist", path)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
files := map[string]int{
|
||||
databasePath: 11,
|
||||
databasePath + "-wal": 7,
|
||||
databasePath + "-shm": 5,
|
||||
}
|
||||
for path, size := range files {
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
|
||||
t.Fatalf("write fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
|
||||
if err != nil {
|
||||
t.Fatalf("sqliteFileSetSize: %v", err)
|
||||
}
|
||||
if got != 23 {
|
||||
t.Fatalf("size = %d, want 23", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
|
||||
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
|
||||
if _, err := sqliteFileSetSize(dsn); err == nil {
|
||||
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/sqlitetune"
|
||||
)
|
||||
|
||||
func TestMainSQLiteConnectorAppliesBoundedSettings(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("open SQLite connector: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if got := sqlitePragmaInt(t, db, "busy_timeout"); got != mainSQLiteBusyTimeout.Milliseconds() {
|
||||
t.Fatalf("busy_timeout = %d, want %d", got, mainSQLiteBusyTimeout.Milliseconds())
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "synchronous"); got != 1 {
|
||||
t.Fatalf("synchronous = %d, want NORMAL (1)", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "cache_size"); got != -mainSQLiteCacheSizeKB {
|
||||
t.Fatalf("cache_size = %d, want %d", got, -mainSQLiteCacheSizeKB)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "mmap_size"); got != 0 {
|
||||
t.Fatalf("mmap_size = %d, want 0", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "temp_store"); got != 1 {
|
||||
t.Fatalf("temp_store = %d, want FILE (1)", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "cache_spill"); got == 0 {
|
||||
t.Fatal("cache_spill is disabled")
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "wal_autocheckpoint"); got != mainSQLiteWALAutoCheckpoint {
|
||||
t.Fatalf("wal_autocheckpoint = %d, want %d", got, mainSQLiteWALAutoCheckpoint)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "journal_size_limit"); got != mainSQLiteJournalSizeLimit {
|
||||
t.Fatalf("journal_size_limit = %d, want %d", got, mainSQLiteJournalSizeLimit)
|
||||
}
|
||||
|
||||
var journalMode string
|
||||
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
t.Fatalf("read journal_mode: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(journalMode, "wal") {
|
||||
t.Fatalf("journal_mode = %q, want WAL", journalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainSQLiteAutoCheckpointKeepsWALBounded(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("open SQLite connector: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if _, err := db.Exec("CREATE TABLE checkpoint_test (payload BLOB NOT NULL)"); err != nil {
|
||||
t.Fatalf("create checkpoint test table: %v", err)
|
||||
}
|
||||
|
||||
// This produces well beyond one auto-checkpoint interval. The checkpoint
|
||||
// below reports the WAL frame count observed before its own work begins.
|
||||
for range 1024 {
|
||||
if _, err := db.Exec("INSERT INTO checkpoint_test (payload) VALUES (zeroblob(4096))"); err != nil {
|
||||
t.Fatalf("insert checkpoint test row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
t.Fatalf("inspect automatic checkpoint: %v", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
t.Fatalf("automatic checkpoint left a busy WAL: %d", busy)
|
||||
}
|
||||
if logFrames >= 2*mainSQLiteWALAutoCheckpoint {
|
||||
t.Fatalf("WAL retained %d frames after %d-frame auto-checkpoint interval", logFrames, mainSQLiteWALAutoCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func sqlitePragmaInt(t *testing.T, db *sql.DB, pragma string) int64 {
|
||||
t.Helper()
|
||||
|
||||
var value int64
|
||||
if err := db.QueryRow("PRAGMA " + pragma).Scan(&value); err != nil {
|
||||
t.Fatalf("read %s: %v", pragma, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetMessageSenderConfigByName(name string) (*models.MessageSenderProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.MessageSenderProvider
|
||||
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func SaveMessageSenderConfig(config *models.MessageSenderProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Save(config).Error
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
|
||||
// OAuth 配置
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// Telegram Bot 配置
|
||||
TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -7,6 +7,6 @@ type Log struct {
|
||||
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);"`
|
||||
Message string `json:"message" gorm:"type:text;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
|
||||
Time time.Time `json:"time" gorm:"autoCreateTime;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null;index:idx_logs_msg_type_time,priority:1"`
|
||||
Time time.Time `json:"time" gorm:"autoCreateTime;not null;index:idx_logs_time;index:idx_logs_msg_type_time,priority:2"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package messageevent
|
||||
|
||||
const (
|
||||
Offline = "Offline"
|
||||
Online = "Online"
|
||||
Expire = "Expire"
|
||||
Renew = "Renew"
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
DReport = "DReport" // 日报
|
||||
WReport = "WReport" // 周报
|
||||
MReport = "MReport" // 月报
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type MessageSenderProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event any `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message any `json:"message"`
|
||||
Emoji any `json:"emoji"`
|
||||
}
|
||||
+83
-27
@@ -1,35 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client represents a registered client device
|
||||
type Client struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
Hidden bool `json:"hidden" gorm:"default:false"`
|
||||
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
|
||||
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
@@ -40,14 +52,15 @@ type User struct {
|
||||
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
type Session struct {
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
@@ -60,7 +73,7 @@ type Session struct {
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time time.Time `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
@@ -76,7 +89,50 @@ type Record struct {
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
|
||||
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
|
||||
}
|
||||
|
||||
// GPURecord logs individual GPU metrics over time
|
||||
type GPURecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time time.Time `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
// StringArray 存储为 JSON 的字符串切片类型
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
|
||||
tests := map[string]interface{}{
|
||||
"bytes": []byte(`["a","b"]`),
|
||||
"text": `["a","b"]`,
|
||||
"empty": "",
|
||||
"nil": nil,
|
||||
}
|
||||
|
||||
for name, input := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var got StringArray
|
||||
if err := got.Scan(input); err != nil {
|
||||
t.Fatalf("scan StringArray: %v", err)
|
||||
}
|
||||
if input == "" || input == nil {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty StringArray, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected StringArray: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Notification 定义了通知相关的数据库模型
|
||||
type OfflineNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
type LoadNotification struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// TrafficReportNotification 定义了流量定时报告的数据库模型
|
||||
type TrafficReportNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
|
||||
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
|
||||
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type OidcProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PingRecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time time.Time `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
// PingTask 表示一次延迟监测任务配置。
|
||||
type PingTask struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
|
||||
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
|
||||
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
}
|
||||
|
||||
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
|
||||
func (task PingTask) AppliesToClient(uuid string) bool {
|
||||
if uuid == "" {
|
||||
return false
|
||||
}
|
||||
for _, client := range task.Clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package models
|
||||
|
||||
// Plugin is the manifest of an installed plugin (komari-plugin.json).
|
||||
//
|
||||
// Name, Description and Author accept either a plain string or an i18n map
|
||||
// like {"zh_CN": "...", "en": "..."}, mirroring the theme manifest. The
|
||||
// backend passes the value through and the frontend resolves it against the
|
||||
// current locale.
|
||||
type Plugin struct {
|
||||
Name any `json:"name"`
|
||||
Short string `json:"short"` // directory name under data/plugin
|
||||
Description any `json:"description"`
|
||||
Author any `json:"author"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
Komari string `json:"komari"` // supported server version constraint, e.g. ">=0.0.1"
|
||||
Entry string `json:"entry"` // entry script, defaults to "script.js"
|
||||
Permissions PluginPermissions `json:"permissions"`
|
||||
Configuration Configuration `json:"configuration"` // declared config items, same shape as themes
|
||||
Pages []PluginPage `json:"pages,omitempty"` // injected admin pages
|
||||
}
|
||||
|
||||
// PluginPermissions declares the plugin capabilities that require admin
|
||||
// approval. Every field defaults to its zero value: nothing is granted
|
||||
// unless declared.
|
||||
//
|
||||
// The following capabilities are always granted and never require approval:
|
||||
// reading the plugin's own configuration (server.getConfig), registering
|
||||
// plugin-owned RPC methods (server.registerRPC), and reading/writing files
|
||||
// inside the plugin directory.
|
||||
type PluginPermissions struct {
|
||||
Node bool `json:"node"` // Node.js compatibility modules (runtime setting, not approval-relevant)
|
||||
AllowSystemRPC bool `json:"allowSystemRPC"` // server.call: call system RPC methods with admin authority
|
||||
AllowRoutes bool `json:"allowRoutes"` // server.route: register HTTP routes on the host engine
|
||||
AllowHooks bool `json:"allowHooks"` // server.hook: modify HTTP requests/responses and intercept WebSocket connections/frames
|
||||
AllowHTMLInject bool `json:"allowHTMLInject"` // server.injectHTML: embed CSS/JS into every HTML response
|
||||
AllowExec bool `json:"allowExec"` // child_process: execute child processes
|
||||
AllowListen bool `json:"allowListen"` // net/http servers: listen on local ports
|
||||
AllowAllFileAccess bool `json:"allowAllFileAccess"` // access files outside the plugin directory
|
||||
MaxHTTPBodyBytes int64 `json:"maxHTTPBodyBytes"` // runtime limit, not approval-relevant
|
||||
MaxChildOutputBytes int `json:"maxChildOutputBytes"` // runtime limit, not approval-relevant
|
||||
TimeoutSeconds int `json:"timeout"` // per-turn execution timeout in seconds, not approval-relevant
|
||||
}
|
||||
|
||||
// PageVisibility controls who can reach a plugin page.
|
||||
type PageVisibility string
|
||||
|
||||
const (
|
||||
// PageVisibilityAdmin limits the page to the admin sidebar.
|
||||
PageVisibilityAdmin PageVisibility = "admin"
|
||||
// PageVisibilityPublic serves the page through the public route without
|
||||
// navigation, mirroring the theme public behavior.
|
||||
PageVisibilityPublic PageVisibility = "public"
|
||||
)
|
||||
|
||||
// PageType controls how a plugin page is presented.
|
||||
type PageType string
|
||||
|
||||
const (
|
||||
// PageTypeIframe embeds a static file from the plugin directory.
|
||||
PageTypeIframe PageType = "iframe"
|
||||
// PageTypeRedirect navigates to an internal site path, mirroring the
|
||||
// theme redirect configuration. The URL must stay on the same origin.
|
||||
PageTypeRedirect PageType = "redirect"
|
||||
)
|
||||
|
||||
// PluginPage declares a page provided by the plugin. An iframe page is served
|
||||
// from the plugin directory (File); a redirect page navigates to an internal
|
||||
// site path (URL). Visibility defaults to admin so existing packages keep
|
||||
// their current behavior.
|
||||
type PluginPage struct {
|
||||
File string `json:"file"`
|
||||
Title any `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Type PageType `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Visibility PageVisibility `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// PluginConfiguration stores the saved configuration values of one plugin,
|
||||
// mirroring ThemeConfiguration.
|
||||
type PluginConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
+8
-25
@@ -1,35 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
import "time"
|
||||
|
||||
type Task struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36)"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeConfigurationManaged = "managed"
|
||||
ThemeConfigurationRaw = "raw"
|
||||
ThemeConfigurationRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name any `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help any `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
func (t Theme) ConfigurationType() string {
|
||||
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
|
||||
if typ == "" {
|
||||
return ThemeConfigurationManaged
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func (t Theme) RawHTML() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRaw {
|
||||
return "", false
|
||||
}
|
||||
return configurationDataString(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) RedirectTarget() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRedirect {
|
||||
return "", false
|
||||
}
|
||||
return NormalizeThemeRedirectTarget(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) ValidateConfiguration() error {
|
||||
switch t.ConfigurationType() {
|
||||
case ThemeConfigurationManaged:
|
||||
return nil
|
||||
case ThemeConfigurationRaw:
|
||||
html, ok := t.RawHTML()
|
||||
if !ok || strings.TrimSpace(html) == "" {
|
||||
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
|
||||
}
|
||||
return nil
|
||||
case ThemeConfigurationRedirect:
|
||||
if _, ok := t.RedirectTarget(); !ok {
|
||||
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeThemeRedirectTarget(data any) (string, bool) {
|
||||
target, ok := configurationDataString(data)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cleanInputPath := parsed.Path
|
||||
if strings.HasPrefix(cleanInputPath, "/") {
|
||||
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
|
||||
} else {
|
||||
for strings.HasPrefix(cleanInputPath, "../") {
|
||||
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
|
||||
}
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(cleanInputPath, "/") {
|
||||
if segment == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath := cleanInputPath
|
||||
if cleanPath == "" {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
if cleanPath == "." {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := url.URL{
|
||||
Path: cleanPath,
|
||||
RawQuery: parsed.RawQuery,
|
||||
Fragment: parsed.Fragment,
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func configurationDataString(data any) (string, bool) {
|
||||
value, ok := data.(string)
|
||||
return value, ok
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThemeValidateConfiguration(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
theme Theme
|
||||
wantErr bool
|
||||
}{
|
||||
"managed empty data": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: ThemeConfigurationManaged},
|
||||
},
|
||||
},
|
||||
"missing type defaults to managed": {
|
||||
theme: Theme{},
|
||||
},
|
||||
"raw html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: "<!doctype html><title>raw</title>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"raw rejects empty html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: " ",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect relative path": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/dashboard?tab=nodes",
|
||||
},
|
||||
},
|
||||
},
|
||||
"redirect rejects absolute url": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "https://example.com",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect rejects parent traversal": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/../admin",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"unknown type": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: "hosted"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := tt.theme.ValidateConfiguration()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeThemeRedirectTarget(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
"leading slash": {
|
||||
input: "/nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"missing leading slash": {
|
||||
input: "nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"parent prefix points to root path": {
|
||||
input: "../settings",
|
||||
want: "/settings",
|
||||
ok: true,
|
||||
},
|
||||
"multiple parent prefixes point to root path": {
|
||||
input: "../../settings/site",
|
||||
want: "/settings/site",
|
||||
ok: true,
|
||||
},
|
||||
"query and fragment": {
|
||||
input: "nodes?id=1#cpu",
|
||||
want: "/nodes?id=1#cpu",
|
||||
ok: true,
|
||||
},
|
||||
"absolute parent traversal": {
|
||||
input: "/../admin",
|
||||
},
|
||||
"nested parent traversal": {
|
||||
input: "nodes/../admin",
|
||||
},
|
||||
"protocol relative": {
|
||||
input: "//example.com",
|
||||
},
|
||||
"backslash": {
|
||||
input: `\admin`,
|
||||
},
|
||||
"absolute url": {
|
||||
input: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := NormalizeThemeRedirectTarget(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("target = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Log{}); err != nil {
|
||||
t.Fatalf("migrate log: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
|
||||
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatalf("create log: %v", err)
|
||||
}
|
||||
var loaded Log
|
||||
if err := db.First(&loaded, entry.ID).Error; err != nil {
|
||||
t.Fatalf("load log: %v", err)
|
||||
}
|
||||
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
|
||||
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(loaded)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Client{}); err != nil {
|
||||
t.Fatalf("migrate client: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
|
||||
t.Fatalf("insert client: %v", err)
|
||||
}
|
||||
|
||||
var client Client
|
||||
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
|
||||
t.Fatalf("load client: %v", err)
|
||||
}
|
||||
if client.ExpiredAt != nil {
|
||||
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
|
||||
}
|
||||
encoded, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal client: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"expired_at":null`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
notification := models.LoadNotification{
|
||||
Clients: clients,
|
||||
Name: name,
|
||||
Metric: metric,
|
||||
Threshold: threshold,
|
||||
Ratio: ratio,
|
||||
Interval: interval,
|
||||
}
|
||||
if err := db.Create(¬ification).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return notification.Id, ReloadLoadNotificationSchedule()
|
||||
}
|
||||
func DeleteLoadNotification(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func EditLoadNotification(notifications []*models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, notification := range notifications {
|
||||
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.LoadNotification
|
||||
if err := db.Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func ReloadLoadNotificationSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var loadNotifications []models.LoadNotification
|
||||
if err := db.Find(&loadNotifications).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
|
||||
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Enable: true,
|
||||
}})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
|
||||
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
|
||||
assert.Error(t, err)
|
||||
|
||||
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Daily: true,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, notifications, 1)
|
||||
assert.Equal(t, "client-a", notifications[0].Client)
|
||||
assert.True(t, notifications[0].Enable)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.OidcProvider
|
||||
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func DeleteOidcConfigByName(name string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+20
-217
@@ -1,236 +1,39 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func RecordOne(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
|
||||
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
|
||||
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
|
||||
//
|
||||
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
|
||||
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
|
||||
|
||||
func DeleteAll() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Exec("DELETE FROM Record").Error
|
||||
return metricstore.DeleteAllRecords(context.Background())
|
||||
}
|
||||
|
||||
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
|
||||
return
|
||||
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
}
|
||||
|
||||
func DeleteRecordBefore(before time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("time < ?", before).Delete(&models.Record{}).Error
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
}
|
||||
|
||||
// 计算区间 [0.02, 0.98] 的平均值
|
||||
func QuantileMean(values []float32) float32 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
// 排序数据
|
||||
sorted := make([]float32, len(values))
|
||||
copy(sorted, values)
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i] > sorted[j] {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 区间边缘index
|
||||
lower := int(0.02 * float64(len(sorted)))
|
||||
upper := int(0.98 * float64(len(sorted)))
|
||||
if lower >= upper || lower >= len(sorted) {
|
||||
return 0
|
||||
}
|
||||
|
||||
sum := float32(0)
|
||||
count := 0
|
||||
for i := lower; i < upper && i < len(sorted); i++ {
|
||||
sum += sorted[i]
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / float32(count)
|
||||
// GetRecordMetricMaxByClientAndTime 获取单项监控指标在各时间桶内的最大值。
|
||||
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
|
||||
}
|
||||
|
||||
// 压缩数据库,针对每个ClientUUID,10分钟内的数据不动,24小时内的数据精简为每15分钟一条,3天为每30分钟一条,7天为每1小时一条,30天为每12小时一条。所有精简的数据是取 [0.02,0.98] 区间内的平均值
|
||||
func CompactRecord() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
log.Printf("Compacting records...")
|
||||
var clientUUIDs []string
|
||||
if err := db.Model(&models.Record{}).Distinct("client").Pluck("client", &clientUUIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
oneDayAgo := now.Add(-24 * time.Hour)
|
||||
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
|
||||
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
|
||||
thirtyDaysAgo := now.Add(-30 * 24 * time.Hour)
|
||||
|
||||
for _, clientUUID := range clientUUIDs {
|
||||
// Process each time range with specific aggregation intervals
|
||||
if err := processTimeRange(db, clientUUID, thirtyDaysAgo, sevenDaysAgo, 12*time.Hour, now); err != nil {
|
||||
log.Printf("Error compacting 30d-7d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, sevenDaysAgo, threeDaysAgo, 1*time.Hour, now); err != nil {
|
||||
log.Printf("Error compacting 7d-3d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, threeDaysAgo, oneDayAgo, 30*time.Minute, now); err != nil {
|
||||
log.Printf("Error compacting 3d-1d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, oneDayAgo, tenMinutesAgo, 15*time.Minute, now); err != nil {
|
||||
log.Printf("Error compacting 1d-10m for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if flags.DatabaseType != "sqlite" {
|
||||
return nil // Only vacuum for SQLite
|
||||
}
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
log.Printf("Error vacuuming the database: %v", err)
|
||||
}
|
||||
log.Printf("Record compaction completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, interval time.Duration, now time.Time) error {
|
||||
// Round start time to the nearest interval
|
||||
start = start.Truncate(interval)
|
||||
end = end.Truncate(interval)
|
||||
|
||||
// Get all records in the time range
|
||||
var records []models.Record
|
||||
if err := db.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).
|
||||
Order("time ASC").
|
||||
Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group records by interval
|
||||
type aggregatedRecord struct {
|
||||
StartTime time.Time
|
||||
Records []models.Record
|
||||
}
|
||||
var aggregatedRecords []aggregatedRecord
|
||||
currentGroup := aggregatedRecord{StartTime: start}
|
||||
currentGroup.Records = make([]models.Record, 0)
|
||||
|
||||
for _, record := range records {
|
||||
groupTime := record.Time.Truncate(interval)
|
||||
if !groupTime.Equal(currentGroup.StartTime) {
|
||||
if len(currentGroup.Records) > 0 {
|
||||
aggregatedRecords = append(aggregatedRecords, currentGroup)
|
||||
}
|
||||
currentGroup = aggregatedRecord{StartTime: groupTime}
|
||||
currentGroup.Records = []models.Record{record}
|
||||
} else {
|
||||
currentGroup.Records = append(currentGroup.Records, record)
|
||||
}
|
||||
}
|
||||
if len(currentGroup.Records) > 0 {
|
||||
aggregatedRecords = append(aggregatedRecords, currentGroup)
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
// Delete original records in the time range
|
||||
if err := tx.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).Delete(&models.Record{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert aggregated records
|
||||
for _, agg := range aggregatedRecords {
|
||||
if len(agg.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate averages and sums
|
||||
var sumCPU, sumGPU, sumLOAD, sumTEMP float32
|
||||
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal int64
|
||||
var sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
|
||||
var sumPROCESS, sumConnections, sumConnectionsUDP int
|
||||
count := len(agg.Records)
|
||||
|
||||
for _, r := range agg.Records {
|
||||
sumCPU += r.Cpu
|
||||
sumGPU += r.Gpu
|
||||
sumLOAD += r.Load
|
||||
sumTEMP += r.Temp
|
||||
sumRAM += r.Ram
|
||||
sumRAMTotal += r.RamTotal
|
||||
sumSWAP += r.Swap
|
||||
sumSWAPTotal += r.SwapTotal
|
||||
sumDISK += r.Disk
|
||||
sumDISKTotal += r.DiskTotal
|
||||
sumNETIn += r.NetIn
|
||||
sumNETOut += r.NetOut
|
||||
sumNETTotalUp += r.NetTotalUp
|
||||
sumNETTotalDown += r.NetTotalDown
|
||||
sumPROCESS += r.Process
|
||||
sumConnections += r.Connections
|
||||
sumConnectionsUDP += r.ConnectionsUdp
|
||||
}
|
||||
|
||||
// Create new aggregated record
|
||||
newRecord := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: agg.StartTime,
|
||||
Cpu: sumCPU / float32(count),
|
||||
Gpu: sumGPU / float32(count),
|
||||
Ram: sumRAM / int64(count),
|
||||
RamTotal: sumRAMTotal / int64(count),
|
||||
Swap: sumSWAP / int64(count),
|
||||
SwapTotal: sumSWAPTotal / int64(count),
|
||||
Load: sumLOAD / float32(count),
|
||||
Temp: sumTEMP / float32(count),
|
||||
Disk: sumDISK / int64(count),
|
||||
DiskTotal: sumDISKTotal / int64(count),
|
||||
NetIn: sumNETIn / int64(count),
|
||||
NetOut: sumNETOut / int64(count),
|
||||
NetTotalUp: sumNETTotalUp / int64(count),
|
||||
NetTotalDown: sumNETTotalDown / int64(count),
|
||||
Process: sumPROCESS / count,
|
||||
Connections: sumConnections / count,
|
||||
ConnectionsUdp: sumConnectionsUDP / count,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newRecord).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func Save(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
|
||||
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
|
||||
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
normalizedClients := normalizePingClients(models.StringArray(clients))
|
||||
task := models.PingTask{
|
||||
Clients: normalizedClients,
|
||||
DefaultOn: defaultOn,
|
||||
Name: name,
|
||||
Type: task_type,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append by id to avoid races between concurrent create requests.
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return task.Id, nil
|
||||
}
|
||||
|
||||
func DeletePingTask(id []uint) error {
|
||||
// The metric store is independent from the main database, so clean it first
|
||||
// to avoid leaving history that can no longer be addressed through the task.
|
||||
if err := DeletePingRecords(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// EditPingTask 批量更新延迟监测任务配置。
|
||||
func EditPingTask(tasks []*models.PingTask) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, task := range tasks {
|
||||
task.Clients = normalizePingClients(task.Clients)
|
||||
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
|
||||
updates := map[string]interface{}{
|
||||
"name": task.Name,
|
||||
"clients": task.Clients,
|
||||
"all_clients": task.DefaultOn,
|
||||
"type": task.Type,
|
||||
"target": task.Target,
|
||||
"interval": task.Interval,
|
||||
}
|
||||
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
|
||||
func normalizePingClients(clients models.StringArray) models.StringArray {
|
||||
if clients == nil {
|
||||
return models.StringArray{}
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func GetAllPingTasks() ([]models.PingTask, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
|
||||
func GetPingTasksByClient(uuid string) []models.PingTask {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func UpdatePingTaskOrder(order map[uint]int) error {
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Validate all ids before changing any weights. The update response is
|
||||
// not a reliable existence check because some drivers report zero rows
|
||||
// when the new value equals the current value.
|
||||
ids := make([]uint, 0, len(order))
|
||||
for id := range order {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
|
||||
var existing int64
|
||||
if err := tx.Model(&models.PingTask{}).Where("id IN ?", ids).Count(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != int64(len(ids)) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
weight := order[id]
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
|
||||
// metric store,旧 ping_records 表不再参与。
|
||||
|
||||
func SavePingRecord(record models.PingRecord) error {
|
||||
return metricstore.WritePingRecord(context.Background(), record)
|
||||
}
|
||||
|
||||
func DeletePingRecords(id []uint) error {
|
||||
return metricstore.DeletePingRecordsByTask(context.Background(), id)
|
||||
}
|
||||
|
||||
func DeleteAllPingRecords() error {
|
||||
return metricstore.DeleteAllPingRecords(context.Background())
|
||||
}
|
||||
|
||||
func ReloadPingSchedule() error {
|
||||
pingTasks, err := GetAllPingTasks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return utils.ReloadPingSchedule(pingTasks)
|
||||
}
|
||||
|
||||
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
|
||||
func AddDefaultOnClientUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for _, task := range tasks {
|
||||
exists := false
|
||||
for _, c := range task.Clients {
|
||||
if c == uuid {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
next := append(models.StringArray{}, task.Clients...)
|
||||
next = append(next, uuid)
|
||||
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return ReloadPingSchedule()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
|
||||
}
|
||||
+3
-14
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
|
||||
Result: "",
|
||||
ExitCode: nil,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
if len(taskResults) > 0 {
|
||||
@@ -57,13 +57,6 @@ func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func GetAllTasksResultByUUID(uuid string) ([]models.TaskResult, error) {
|
||||
var results []models.TaskResult
|
||||
if err := dbcore.GetDBInstance().Where("client = ?", uuid).Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func GetAllTasks() ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
|
||||
@@ -79,10 +72,6 @@ func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func DeleteTaskByTaskId(taskId string) error {
|
||||
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
|
||||
}
|
||||
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
@@ -90,10 +79,10 @@ func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp tim
|
||||
Updates(map[string]interface{}{
|
||||
"result": result,
|
||||
"exit_code": exitCode,
|
||||
"finished_at": timestamp,
|
||||
"finished_at": timestamp.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func ClearTaskResultsByTimeBefore(before time.Time) error {
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.Format(time.RFC3339)).Delete(&models.TaskResult{}).Error
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.UTC()).Delete(&models.TaskResult{}).Error
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
taskID := "task-cleanup-time"
|
||||
if err := db.Create(&models.Task{TaskId: taskID, Command: "true"}).Error; err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
cutoff := time.Date(2026, 7, 17, 12, 0, 0, 123456789, time.UTC)
|
||||
results := []models.TaskResult{
|
||||
{TaskId: taskID, Client: "old", CreatedAt: cutoff.Add(-time.Nanosecond)},
|
||||
{TaskId: taskID, Client: "boundary", CreatedAt: cutoff},
|
||||
{TaskId: taskID, Client: "new", CreatedAt: cutoff.Add(time.Nanosecond)},
|
||||
}
|
||||
if err := db.Create(&results).Error; err != nil {
|
||||
t.Fatalf("create task results: %v", err)
|
||||
}
|
||||
|
||||
localCutoff := cutoff.In(time.FixedZone("UTC+8", 8*60*60))
|
||||
if err := ClearTaskResultsByTimeBefore(localCutoff); err != nil {
|
||||
t.Fatalf("clear task results: %v", err)
|
||||
}
|
||||
var remaining []models.TaskResult
|
||||
if err := db.Where("task_id = ?", taskID).Order("created_at").Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("load remaining results: %v", err)
|
||||
}
|
||||
if len(remaining) != 2 || remaining[0].Client != "boundary" || remaining[1].Client != "new" {
|
||||
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
tasks := []models.PingTask{
|
||||
{Name: "third", Weight: 2, Type: "icmp", Target: "third.example", Interval: 60},
|
||||
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "second", Weight: 0, Type: "icmp", Target: "second.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&tasks).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
ordered, err := GetAllPingTasks()
|
||||
if err != nil {
|
||||
t.Fatalf("get ordered ping tasks: %v", err)
|
||||
}
|
||||
if len(ordered) != len(tasks) {
|
||||
t.Fatalf("ordered ping task count = %d, want %d", len(ordered), len(tasks))
|
||||
}
|
||||
if ordered[0].Id != tasks[1].Id || ordered[1].Id != tasks[2].Id || ordered[2].Id != tasks[0].Id {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_update_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "second", Weight: 1, Type: "icmp", Target: "second.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
if err := UpdatePingTaskOrder(map[uint]int{
|
||||
items[0].Id: 0,
|
||||
items[1].Id: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("update ping task order: %v", err)
|
||||
}
|
||||
|
||||
var first, second models.PingTask
|
||||
if err := db.First(&first, items[0].Id).Error; err != nil {
|
||||
t.Fatalf("load first ping task: %v", err)
|
||||
}
|
||||
if err := db.First(&second, items[1].Id).Error; err != nil {
|
||||
t.Fatalf("load second ping task: %v", err)
|
||||
}
|
||||
if first.Weight != 0 || second.Weight != 2 {
|
||||
t.Fatalf("weights = [%d, %d], want [0, 2]", first.Weight, second.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_client_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "third", Weight: 2, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "third.example", Interval: 60},
|
||||
{Name: "first", Weight: 0, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "other", Weight: 1, Clients: models.StringArray{"node-b"}, Type: "icmp", Target: "other.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
ordered := GetPingTasksByClient("node-a")
|
||||
if len(ordered) != 2 || ordered[0].Id != items[1].Id || ordered[1].Id != items[0].Id {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d]", ordered, items[1].Id, items[0].Id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (map[string]interface{}, error) {
|
||||
cstPtr, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cst := *cstPtr
|
||||
|
||||
all, allErr := config.GetAll()
|
||||
hasKey := func(k string) bool {
|
||||
if allErr != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := all[k]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Apply defaults only when a key is missing.
|
||||
if !hasKey("sitename") {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if !hasKey("description") {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if !hasKey("theme") {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if !hasKey("o_auth_provider") {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
|
||||
// Fallback defaults if we couldn't enumerate keys.
|
||||
if allErr != nil {
|
||||
if cst.Sitename == "" {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if cst.Description == "" {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
}
|
||||
retention, err := metricstore.GetRetentionSummary(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
if err != nil {
|
||||
tc.Data = "{}"
|
||||
}
|
||||
tc_data := gin.H{}
|
||||
err = json.Unmarshal([]byte(tc.Data), &tc_data)
|
||||
if err != nil {
|
||||
logger.Infof("database", "%v", err)
|
||||
}
|
||||
// Try to load theme declaration file and merge defaults for managed configuration
|
||||
// Theme declarations live in ./data/theme/<short>/komari-theme.json
|
||||
if cst.Theme != "" && cst.Theme != "default" {
|
||||
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
|
||||
if _, err := os.Stat(themeConfigPath); err == nil {
|
||||
b, err := os.ReadFile(themeConfigPath)
|
||||
if err == nil {
|
||||
var themeDecl struct {
|
||||
Configuration struct {
|
||||
Type string `json:"type"`
|
||||
Data []models.ManagedThemeConfigurationItem `json:"data"`
|
||||
} `json:"configuration"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &themeDecl); err == nil {
|
||||
if themeDecl.Configuration.Type == "managed" {
|
||||
for _, item := range themeDecl.Configuration.Data {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
// missing
|
||||
if _, exists := tc_data[item.Key]; !exists {
|
||||
var def any = item.Default
|
||||
// select
|
||||
if item.Type == "select" {
|
||||
if def == nil || def == "" {
|
||||
if item.Options != "" {
|
||||
opts := strings.Split(item.Options, ",")
|
||||
if len(opts) > 0 {
|
||||
def = strings.TrimSpace(opts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// number->0, string->"", switch->false
|
||||
if def == nil {
|
||||
switch item.Type {
|
||||
case "number":
|
||||
def = 0
|
||||
case "switch":
|
||||
def = false
|
||||
default:
|
||||
def = ""
|
||||
}
|
||||
}
|
||||
tc_data[item.Key] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
"description": cst.Description,
|
||||
"custom_head": cst.CustomHead,
|
||||
"custom_body": cst.CustomBody,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"oauth_provider": cst.OAuthProvider,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
|
||||
"record_enabled": retention.AllPositive, // 兼容旧版本主题
|
||||
"record_preserve_time": retention.MaxDays * 24,
|
||||
"ping_record_preserve_time": retention.MaxDays * 24,
|
||||
"private_site": cst.PrivateSite,
|
||||
"visitor_audit_enabled": cst.VisitorAuditEnabled,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,53 +1,65 @@
|
||||
module github.com/komari-monitor/komari
|
||||
|
||||
go 1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/sys v0.33.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
|
||||
)
|
||||
|
||||
require github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||
|
||||
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
|
||||
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
|
||||
require golang.org/x/crypto v0.37.0 // indirect
|
||||
require golang.org/x/crypto v0.39.0
|
||||
|
||||
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
|
||||
// golang.org/x/net vulnerable to Cross-site Scripting #4
|
||||
require golang.org/x/net v0.39.0 // indirect
|
||||
require golang.org/x/net v0.41.0 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
@@ -57,9 +69,8 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/text v0.33.0
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -15,6 +17,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
@@ -29,32 +39,44 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -74,6 +96,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -81,7 +105,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -99,23 +122,28 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
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=
|
||||
@@ -123,14 +151,13 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
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=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
@@ -0,0 +1,629 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Color definitions for terminal output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "$1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}$1${NC}"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
|
||||
# Global variables
|
||||
INSTALL_DIR="/opt/komari"
|
||||
DATA_DIR="/opt/komari"
|
||||
SERVICE_NAME="komari"
|
||||
BINARY_PATH="$INSTALL_DIR/komari"
|
||||
DEFAULT_PORT="25774"
|
||||
LISTEN_PORT=""
|
||||
REPO="komari-monitor/komari"
|
||||
# 发布通道: stable(稳定版)或 snapshot(快照版)
|
||||
CHANNEL="stable"
|
||||
# TUI 工具: whiptail / dialog / 空(回退纯文本)
|
||||
TUI_TOOL=""
|
||||
|
||||
# ==========================================================
|
||||
# TUI / 交互层
|
||||
# ==========================================================
|
||||
|
||||
# 检测可用的 TUI 工具
|
||||
detect_tui() {
|
||||
if command -v whiptail >/dev/null 2>&1; then
|
||||
TUI_TOOL="whiptail"
|
||||
elif command -v dialog >/dev/null 2>&1; then
|
||||
TUI_TOOL="dialog"
|
||||
else
|
||||
TUI_TOOL=""
|
||||
fi
|
||||
}
|
||||
|
||||
# 是否启用 TUI
|
||||
tui_enabled() {
|
||||
[ -n "$TUI_TOOL" ]
|
||||
}
|
||||
|
||||
# 菜单选择
|
||||
# 用法: ui_menu "标题" "提示" tag1 "item1" tag2 "item2" ...
|
||||
# 返回: 选中的 tag(输出到 stdout),取消返回非零
|
||||
ui_menu() {
|
||||
local title="$1"; shift
|
||||
local prompt="$1"; shift
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --menu "$prompt" 20 70 10 "$@" 3>&1 1>&2 2>&3
|
||||
return $?
|
||||
fi
|
||||
|
||||
# 纯文本回退
|
||||
{
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $title"
|
||||
echo "=============================================================="
|
||||
echo "$prompt"
|
||||
echo
|
||||
local tag item
|
||||
local args=("$@")
|
||||
local i=0
|
||||
while [ $i -lt ${#args[@]} ]; do
|
||||
tag="${args[$i]}"
|
||||
item="${args[$((i + 1))]}"
|
||||
echo " $tag) $item"
|
||||
i=$((i + 2))
|
||||
done
|
||||
echo
|
||||
} >&2
|
||||
local choice
|
||||
read -r -p "输入选项: " choice >&2
|
||||
echo "$choice"
|
||||
}
|
||||
|
||||
# 输入框
|
||||
# 用法: ui_input "标题" "提示" "默认值"
|
||||
# 返回: 输入内容(输出到 stdout),取消返回非零
|
||||
ui_input() {
|
||||
local title="$1"
|
||||
local prompt="$2"
|
||||
local default="$3"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --inputbox "$prompt" 12 70 "$default" 3>&1 1>&2 2>&3
|
||||
return $?
|
||||
fi
|
||||
|
||||
local input
|
||||
read -r -p "$prompt [默认: $default]: " input >&2
|
||||
if [ -z "$input" ]; then
|
||||
echo "$default"
|
||||
else
|
||||
echo "$input"
|
||||
fi
|
||||
}
|
||||
|
||||
# 是/否确认
|
||||
# 用法: ui_yesno "标题" "提示"
|
||||
# 返回: 0 表示 是,1 表示 否
|
||||
ui_yesno() {
|
||||
local title="$1"
|
||||
local prompt="$2"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --yesno "$prompt" 12 70
|
||||
return $?
|
||||
fi
|
||||
|
||||
local confirm
|
||||
read -r -p "$prompt (Y/n): " confirm >&2
|
||||
if [[ $confirm =~ ^[Nn]$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 信息提示框
|
||||
# 用法: ui_msgbox "标题" "内容"
|
||||
ui_msgbox() {
|
||||
local title="$1"
|
||||
local content="$2"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --msgbox "$content" 20 72
|
||||
return
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $title"
|
||||
echo "=============================================================="
|
||||
echo -e "$content"
|
||||
echo "=============================================================="
|
||||
read -r -p "按回车键继续..." _
|
||||
}
|
||||
|
||||
# 显示横幅(仅纯文本模式)
|
||||
show_banner() {
|
||||
if tui_enabled; then
|
||||
return
|
||||
fi
|
||||
clear
|
||||
echo "=============================================================="
|
||||
echo " Komari Monitoring System Installer"
|
||||
echo " https://github.com/komari-monitor/komari"
|
||||
echo "=============================================================="
|
||||
echo
|
||||
}
|
||||
|
||||
# 选择发布通道,结果写入全局变量 CHANNEL
|
||||
select_channel() {
|
||||
local choice
|
||||
choice=$(ui_menu "选择发布通道" "请选择要使用的发布通道:" \
|
||||
"stable" "稳定版 (推荐)" \
|
||||
"snapshot" "快照版 (最新功能)")
|
||||
|
||||
case "$choice" in
|
||||
snapshot|2)
|
||||
CHANNEL="snapshot"
|
||||
;;
|
||||
stable|1|"")
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
*)
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
esac
|
||||
log_info "已选择通道: $CHANNEL"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# 基础检查
|
||||
# ==========================================================
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "请使用 root 权限运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for systemd
|
||||
check_systemd() {
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect system architecture
|
||||
detect_arch() {
|
||||
local arch=$(uname -m)
|
||||
case $arch in
|
||||
x86_64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64)
|
||||
echo "arm64"
|
||||
;;
|
||||
i386|i686)
|
||||
echo "386"
|
||||
;;
|
||||
riscv64)
|
||||
echo "riscv64"
|
||||
;;
|
||||
loongarch64|loong64)
|
||||
echo "loong64"
|
||||
;;
|
||||
*)
|
||||
log_error "不支持的架构: $arch"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check if Komari is already installed
|
||||
is_installed() {
|
||||
if [ -f "$BINARY_PATH" ]; then
|
||||
return 0 # 0 means true in bash exit codes
|
||||
else
|
||||
return 1 # 1 means false
|
||||
fi
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
install_dependencies() {
|
||||
log_step "检查并安装依赖..."
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
log_info "使用 apt 安装依赖..."
|
||||
apt update
|
||||
apt install -y curl
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
log_info "使用 yum 安装依赖..."
|
||||
yum install -y curl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
log_info "使用 apk 安装依赖..."
|
||||
apk add curl
|
||||
else
|
||||
log_error "未找到支持的包管理器 (apt/yum/apk)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Get download URL based on channel
|
||||
get_download_url() {
|
||||
local arch=$1
|
||||
local file_name="komari-linux-${arch}"
|
||||
|
||||
if [ "$CHANNEL" = "snapshot" ]; then
|
||||
# 获取最新的 snapshot 预发布版本
|
||||
log_info "获取最新 snapshot 版本..." >&2
|
||||
local latest_snapshot=$(curl -s "https://api.github.com/repos/${REPO}/releases" | grep '"tag_name"' | grep 'Snapshot-' | head -1 | sed -e 's/.*"tag_name": *"//' -e 's/".*//')
|
||||
|
||||
if [ -z "$latest_snapshot" ]; then
|
||||
log_error "未找到 snapshot 版本" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "最新 snapshot 版本: $latest_snapshot" >&2
|
||||
echo "https://github.com/${REPO}/releases/download/${latest_snapshot}/${file_name}"
|
||||
else
|
||||
# 稳定版:使用 latest
|
||||
echo "https://github.com/${REPO}/releases/latest/download/${file_name}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# 业务操作
|
||||
# ==========================================================
|
||||
|
||||
# Binary installation
|
||||
install_binary() {
|
||||
log_step "开始二进制安装..."
|
||||
|
||||
if is_installed; then
|
||||
ui_msgbox "提示" "Komari 已安装。\n如需升级,请使用主菜单中的升级选项。"
|
||||
return
|
||||
fi
|
||||
|
||||
# 选择发布通道
|
||||
select_channel
|
||||
|
||||
# 监听端口输入,校验范围 1-65535
|
||||
while true; do
|
||||
local input_port
|
||||
input_port=$(ui_input "监听端口" "请输入 Komari 的监听端口 (1-65535):" "$DEFAULT_PORT")
|
||||
# 取消输入
|
||||
if [ $? -ne 0 ]; then
|
||||
log_info "安装已取消"
|
||||
return
|
||||
fi
|
||||
if [[ -z "$input_port" ]]; then
|
||||
LISTEN_PORT="$DEFAULT_PORT"
|
||||
break
|
||||
elif [[ "$input_port" =~ ^[0-9]+$ ]] && (( input_port >= 1 && input_port <= 65535 )); then
|
||||
LISTEN_PORT="$input_port"
|
||||
break
|
||||
else
|
||||
ui_msgbox "错误" "端口号无效,请输入 1-65535 之间的数字。"
|
||||
fi
|
||||
done
|
||||
|
||||
install_dependencies
|
||||
|
||||
local arch=$(detect_arch)
|
||||
log_info "检测到架构: $arch"
|
||||
|
||||
log_step "创建安装目录: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
log_step "创建数据目录: $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
local download_url=$(get_download_url "$arch")
|
||||
if [ $? -ne 0 ]; then
|
||||
ui_msgbox "错误" "获取下载链接失败,请检查网络连接或稍后重试。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "下载 Komari 二进制文件..."
|
||||
log_info "URL: $download_url"
|
||||
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
ui_msgbox "错误" "下载失败,请检查网络连接。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$BINARY_PATH"
|
||||
log_success "Komari 二进制文件安装完成: $BINARY_PATH"
|
||||
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "安装完成" "警告:未检测到 systemd,已跳过服务创建。\n\n您可以手动运行 Komari:\n $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
|
||||
return
|
||||
fi
|
||||
|
||||
create_systemd_service "$LISTEN_PORT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}.service
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
log_success "Komari 服务启动成功"
|
||||
|
||||
show_access_info "$LISTEN_PORT"
|
||||
else
|
||||
ui_msgbox "错误" "Komari 服务启动失败。\n\n查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Create systemd service file
|
||||
create_systemd_service() {
|
||||
local port="$1"
|
||||
log_step "创建 systemd 服务..."
|
||||
|
||||
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
cat > "$service_file" << EOF
|
||||
[Unit]
|
||||
Description=Komari Monitor Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
|
||||
WorkingDirectory=${DATA_DIR}
|
||||
Restart=always
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
log_success "systemd 服务文件创建完成"
|
||||
}
|
||||
|
||||
# Show access information
|
||||
show_access_info() {
|
||||
local port=${1:-$DEFAULT_PORT}
|
||||
local ip=$(hostname -I | awk '{print $1}')
|
||||
|
||||
local content="安装完成!\n\n"
|
||||
content+="访问信息:\n"
|
||||
content+=" URL: http://${ip}:${port}\n"
|
||||
content+="\n首次使用请访问上述地址,按安装向导创建管理员账号。\n"
|
||||
content+="\n服务管理命令:\n"
|
||||
content+=" 状态: systemctl status $SERVICE_NAME\n"
|
||||
content+=" 启动: systemctl start $SERVICE_NAME\n"
|
||||
content+=" 停止: systemctl stop $SERVICE_NAME\n"
|
||||
content+=" 重启: systemctl restart $SERVICE_NAME\n"
|
||||
content+=" 日志: journalctl -u $SERVICE_NAME -f"
|
||||
|
||||
ui_msgbox "安装完成" "$content"
|
||||
}
|
||||
|
||||
# Upgrade function
|
||||
upgrade_komari() {
|
||||
log_step "升级 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。请先安装它。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法管理服务。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 选择发布通道
|
||||
select_channel
|
||||
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
|
||||
log_step "备份当前二进制文件..."
|
||||
cp "$BINARY_PATH" "${BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
local arch=$(detect_arch)
|
||||
local download_url=$(get_download_url "$arch")
|
||||
if [ $? -ne 0 ]; then
|
||||
log_error "获取下载链接失败,正在从备份恢复"
|
||||
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
ui_msgbox "错误" "获取下载链接失败,已从备份恢复。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "下载最新版本..."
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
log_error "下载失败,正在从备份恢复"
|
||||
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
ui_msgbox "错误" "下载失败,已从备份恢复。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
log_step "重启 Komari 服务..."
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
ui_msgbox "升级完成" "Komari 升级成功 (通道: $CHANNEL)。"
|
||||
else
|
||||
ui_msgbox "错误" "服务在升级后未能启动,请检查日志。"
|
||||
fi
|
||||
}
|
||||
|
||||
# Uninstall function
|
||||
uninstall_komari() {
|
||||
log_step "卸载 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
ui_msgbox "提示" "Komari 未安装。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ui_yesno "确认卸载" "这将删除 Komari 二进制文件和服务。\n\n您确定要继续吗?"; then
|
||||
log_info "卸载已取消"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if check_systemd; then
|
||||
log_step "停止并禁用服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service >/dev/null 2>&1
|
||||
systemctl disable ${SERVICE_NAME}.service >/dev/null 2>&1
|
||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
log_success "systemd 服务已删除"
|
||||
fi
|
||||
|
||||
log_step "删除二进制文件..."
|
||||
rm -f "$BINARY_PATH"
|
||||
# 尝试在目录为空时删除该目录
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || log_info "数据目录 $INSTALL_DIR 不为空,未删除"
|
||||
log_success "Komari 二进制文件已删除"
|
||||
|
||||
ui_msgbox "卸载完成" "Komari 卸载完成。\n\n数据文件保留在 $DATA_DIR"
|
||||
}
|
||||
|
||||
# Show service status
|
||||
show_status() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法获取服务状态。"
|
||||
return
|
||||
fi
|
||||
if tui_enabled; then
|
||||
local status_output
|
||||
status_output=$(systemctl status ${SERVICE_NAME}.service --no-pager -l 2>&1)
|
||||
ui_msgbox "服务状态" "$status_output"
|
||||
else
|
||||
log_step "Komari 服务状态:"
|
||||
systemctl status ${SERVICE_NAME}.service --no-pager -l
|
||||
read -r -p "按回车键继续..." _
|
||||
fi
|
||||
}
|
||||
|
||||
# Show service logs
|
||||
show_logs() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法获取服务日志。"
|
||||
return
|
||||
fi
|
||||
# 日志为实时流,直接在终端显示
|
||||
if tui_enabled; then
|
||||
clear
|
||||
fi
|
||||
log_step "查看 Komari 服务日志 (按 Ctrl+C 退出)..."
|
||||
journalctl -u ${SERVICE_NAME} -f --no-pager
|
||||
}
|
||||
|
||||
# Restart service
|
||||
restart_service() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法重启服务。"
|
||||
return
|
||||
fi
|
||||
log_step "重启 Komari 服务..."
|
||||
systemctl restart ${SERVICE_NAME}.service
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
ui_msgbox "成功" "服务重启成功。"
|
||||
else
|
||||
ui_msgbox "错误" "服务重启失败,请检查日志。"
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop service
|
||||
stop_service() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法停止服务。"
|
||||
return
|
||||
fi
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
ui_msgbox "成功" "服务已停止。"
|
||||
}
|
||||
|
||||
|
||||
# Main menu
|
||||
main_menu() {
|
||||
while true; do
|
||||
show_banner
|
||||
|
||||
local choice
|
||||
choice=$(ui_menu "Komari 监控系统安装器" "请选择操作:" \
|
||||
"1" "安装 Komari" \
|
||||
"2" "升级 Komari" \
|
||||
"3" "卸载 Komari" \
|
||||
"4" "查看状态" \
|
||||
"5" "查看日志" \
|
||||
"6" "重启服务" \
|
||||
"7" "停止服务" \
|
||||
"8" "退出")
|
||||
|
||||
# 用户在 TUI 中取消(ESC/Cancel)则退出
|
||||
if [ $? -ne 0 ] && tui_enabled; then
|
||||
clear
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $choice in
|
||||
1) install_binary ;;
|
||||
2) upgrade_komari ;;
|
||||
3) uninstall_komari ;;
|
||||
4) show_status ;;
|
||||
5) show_logs ;;
|
||||
6) restart_service ;;
|
||||
7) stop_service ;;
|
||||
8)
|
||||
tui_enabled && clear
|
||||
exit 0
|
||||
;;
|
||||
*) ui_msgbox "错误" "无效选项" ;;
|
||||
esac
|
||||
|
||||
# 纯文本模式下单次执行后退出循环(保持原有行为,避免输出被覆盖)
|
||||
if ! tui_enabled; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main execution
|
||||
check_root
|
||||
detect_tui
|
||||
main_menu
|
||||
@@ -0,0 +1,544 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
Key string `gorm:"primaryKey;column:key;type:text"`
|
||||
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
|
||||
}
|
||||
|
||||
func (ConfigItem) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
SetDb = func(gdb *gorm.DB) {
|
||||
db = gdb
|
||||
if err := db.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
panic("failed to migrate config item table: " + err.Error())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
|
||||
func GetAs[T any](key string, defaul ...any) (T, error) {
|
||||
var t T
|
||||
var item ConfigItem
|
||||
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
// 尝试直接类型断言
|
||||
if v, ok := defaul[0].(T); ok {
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
// 尝试类型转换
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
if err := convertAndSet(defaul[0], val); err != nil {
|
||||
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
|
||||
}
|
||||
err = Set(key, t)
|
||||
return t, err
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// 先尝试直接反序列化
|
||||
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
|
||||
// 尝试通用解析后转换
|
||||
var generic any
|
||||
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
|
||||
return t, err
|
||||
}
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
if err := convertAndSet(generic, val); err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
|
||||
// 如果 defaultValue 为 nil,则数据库不存在时不写入
|
||||
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
|
||||
func GetMany(keys map[string]any) (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
keyList := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
keyList = append(keyList, k)
|
||||
}
|
||||
if len(keyList) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
foundKeys[item.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 收集需要写入数据库的默认值
|
||||
var toInsert []ConfigItem
|
||||
for k, def := range keys {
|
||||
if _, found := foundKeys[k]; !found {
|
||||
if def != nil {
|
||||
result[k] = def
|
||||
// 序列化后加入待写入列表
|
||||
jsonBytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
logger.Warn("config", "marshal default value failed", "key", k, "error", err)
|
||||
continue
|
||||
}
|
||||
toInsert = append(toInsert, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量写入默认值到数据库
|
||||
if len(toInsert) > 0 {
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&toInsert).Error; err != nil {
|
||||
logger.Warn("config", "batch insert default config failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
|
||||
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
|
||||
// 没有 default tag 的字段使用零值,不写入数据库
|
||||
func GetManyAs[T any]() (*T, error) {
|
||||
var t T
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
type fieldInfo struct {
|
||||
index int
|
||||
key string
|
||||
hasDefault bool
|
||||
defaultVal string
|
||||
}
|
||||
|
||||
fields := make([]fieldInfo, 0)
|
||||
keys := make([]string, 0)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
// 解析 json tag,处理 "key,omitempty" 格式
|
||||
key := strings.Split(jsonTag, ",")[0]
|
||||
if key == "" || key == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
defaultTag := field.Tag.Get("default")
|
||||
// 检查是否显式定义了 default tag (即使值为空)
|
||||
_, hasDefault := field.Tag.Lookup("default")
|
||||
|
||||
fields = append(fields, fieldInfo{
|
||||
index: i,
|
||||
key: key,
|
||||
hasDefault: hasDefault,
|
||||
defaultVal: defaultTag,
|
||||
})
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
var items []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 建立数据库中存在的 key 映射
|
||||
foundItems := make(map[string]string) // key -> value
|
||||
for _, item := range items {
|
||||
foundItems[item.Key] = item.Value
|
||||
}
|
||||
|
||||
// 需要写入数据库的新配置项
|
||||
var toInsert []ConfigItem
|
||||
|
||||
for _, fi := range fields {
|
||||
fieldVal := val.Field(fi.index)
|
||||
if !fieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
if dbValue, found := foundItems[fi.key]; found {
|
||||
// 数据库中存在,使用数据库值
|
||||
if err := unmarshalToField(dbValue, fieldVal); err != nil {
|
||||
logger.Warn("config", "unmarshal config failed", "key", fi.key, "error", err)
|
||||
}
|
||||
} else if fi.hasDefault {
|
||||
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
|
||||
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
|
||||
logger.Warn("config", "parse default value failed", "key", fi.key, "error", err)
|
||||
continue
|
||||
}
|
||||
// 序列化后写入数据库
|
||||
jsonBytes, err := json.Marshal(fieldVal.Interface())
|
||||
if err != nil {
|
||||
logger.Warn("config", "marshal default value failed", "key", fi.key, "error", err)
|
||||
continue
|
||||
}
|
||||
toInsert = append(toInsert, ConfigItem{
|
||||
Key: fi.key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
|
||||
}
|
||||
|
||||
// 批量写入默认值到数据库
|
||||
if len(toInsert) > 0 {
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&toInsert).Error; err != nil {
|
||||
logger.Warn("config", "batch insert default config failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
|
||||
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
|
||||
// 尝试通用解析后转换
|
||||
var generic any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
|
||||
return err
|
||||
}
|
||||
return convertAndSet(generic, fieldVal)
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDefaultToField 解析 default tag 值到字段
|
||||
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
|
||||
kind := fieldVal.Kind()
|
||||
|
||||
switch kind {
|
||||
case reflect.String:
|
||||
fieldVal.SetString(defaultVal)
|
||||
case reflect.Bool:
|
||||
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
var v int64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
|
||||
// 尝试解析浮点数后转换
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
|
||||
return err
|
||||
}
|
||||
v = int64(f)
|
||||
}
|
||||
}
|
||||
fieldVal.SetInt(v)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
var v uint64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
|
||||
return err
|
||||
}
|
||||
v = uint64(f)
|
||||
}
|
||||
}
|
||||
fieldVal.SetUint(v)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
var v float64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fieldVal.SetFloat(v)
|
||||
default:
|
||||
// 对于复杂类型,尝试 JSON 解析
|
||||
if defaultVal == "" {
|
||||
return nil // 保持零值
|
||||
}
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertAndSet 通用类型转换并设置字段值
|
||||
func convertAndSet(val any, fieldVal reflect.Value) error {
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetType := fieldVal.Type()
|
||||
v := reflect.ValueOf(val)
|
||||
|
||||
// 直接类型匹配
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
fieldVal.Set(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 类型可转换
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
fieldVal.Set(v.Convert(targetType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
|
||||
if f, ok := val.(float64); ok {
|
||||
switch fieldVal.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
fieldVal.SetInt(int64(f))
|
||||
return nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
fieldVal.SetUint(uint64(f))
|
||||
return nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
fieldVal.SetFloat(f)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 回环转换
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := reflect.New(targetType).Interface()
|
||||
if err := json.Unmarshal(b, target); err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAll() (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Set 设置单个配置
|
||||
func Set(key string, value any) error {
|
||||
oldVal := map[string]any{}
|
||||
{
|
||||
var oldItem ConfigItem
|
||||
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
|
||||
oldVal[key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := ConfigItem{
|
||||
Key: key,
|
||||
Value: string(bytes),
|
||||
}
|
||||
|
||||
err = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newVal := map[string]any{key: value}
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetMany(cst map[string]any) error {
|
||||
var items []ConfigItem
|
||||
for k, v := range cst {
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key %s failed: %w", k, err)
|
||||
}
|
||||
items = append(items, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigEvent struct {
|
||||
Old map[string]any
|
||||
New map[string]any
|
||||
}
|
||||
|
||||
func (e ConfigEvent) IsChanged(key string) bool {
|
||||
oldVal, oldOk := e.Old[key]
|
||||
newVal, newOk := e.New[key]
|
||||
if !oldOk && !newOk {
|
||||
return false
|
||||
}
|
||||
if oldOk != newOk {
|
||||
return true
|
||||
}
|
||||
return !reflect.DeepEqual(oldVal, newVal)
|
||||
}
|
||||
|
||||
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
|
||||
changed := e.IsChanged(key)
|
||||
var zero T
|
||||
|
||||
val, ok := e.New[key]
|
||||
if !ok {
|
||||
val, ok = e.Old[key]
|
||||
if !ok {
|
||||
return changed, zero
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// Fast path: direct assertion.
|
||||
if cast, ok := val.(T); ok {
|
||||
return changed, cast
|
||||
}
|
||||
|
||||
// Try reflection-based conversion (covers numeric conversions, etc.).
|
||||
targetType := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v := reflect.ValueOf(val)
|
||||
if v.IsValid() {
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
return changed, v.Interface().(T)
|
||||
}
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
converted := v.Convert(targetType)
|
||||
return changed, converted.Interface().(T)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
|
||||
if b, err := json.Marshal(val); err == nil {
|
||||
var out T
|
||||
if err := json.Unmarshal(b, &out); err == nil {
|
||||
return changed, out
|
||||
}
|
||||
}
|
||||
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal map[string]any) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
for _, sub := range subscribers {
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Settings struct {
|
||||
ID uint `json:"id,omitempty"` // 1
|
||||
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
|
||||
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
|
||||
CorsOriginCheckEnabled bool `json:"cors_origin_check_enabled" default:"true"` // 是否启用 API CORS 跨域请求校验,默认 true
|
||||
CorsAllowedOrigins string `json:"cors_allowed_origins" default:""` // API 跨域允许列表
|
||||
WsOriginCheckEnabled bool `json:"ws_origin_check_enabled" default:"true"` // 是否校验 WebSocket Origin
|
||||
WsAllowedOrigins string `json:"ws_allowed_origins" default:""` // WebSocket Origin 允许列表
|
||||
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" default:"false"`
|
||||
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
|
||||
// OAuth 配置
|
||||
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
|
||||
OAuthProvider string `json:"o_auth_provider" default:"github"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" default:""`
|
||||
CustomBody string `json:"custom_body" default:""`
|
||||
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" default:"none"`
|
||||
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
|
||||
CorsAllowedOriginsKey = "cors_allowed_origins"
|
||||
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
|
||||
WsAllowedOriginsKey = "ws_allowed_origins"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
VisitorAuditEnabledKey = "visitor_audit_enabled"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
BaseScriptsURLKey = "base_scripts_url"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
|
||||
NotificationEnabledKey = "notification_enabled"
|
||||
NotificationMethodKey = "notification_method"
|
||||
NotificationTemplateKey = "notification_template"
|
||||
ExpireNotificationEnabledKey = "expire_notification_enabled"
|
||||
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
UpdatedAtKey = "updated_at"
|
||||
XtermjsSettingsKey = "xtermjs_settings"
|
||||
ThemeMarketSourcesKey = "theme_market_sources"
|
||||
PluginMarketSourcesKey = "plugin_market_sources"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package lifecycle carries process-level requests without coupling web RPC
|
||||
// handlers to the server runtime.
|
||||
package lifecycle
|
||||
|
||||
// RestartReason identifies why the current process must restart.
|
||||
type RestartReason string
|
||||
|
||||
const RestartForMetricStoreStructureUpgrade RestartReason = "metric-store-structure-upgrade"
|
||||
|
||||
var restartRequests = make(chan RestartReason, 1)
|
||||
|
||||
// RequestRestart asks the running server to shut down cleanly and exit. A
|
||||
// pending request is sufficient because the process will be replaced.
|
||||
func RequestRestart(reason RestartReason) {
|
||||
select {
|
||||
case restartRequests <- reason:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// RestartRequests returns process restart requests for the server runtime.
|
||||
func RestartRequests() <-chan RestartReason {
|
||||
return restartRequests
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
if !compactOperations.TryAcquire() {
|
||||
return 0, ErrCompactInProgress
|
||||
}
|
||||
defer compactOperations.Release()
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return 0, fmt.Errorf("wait for metric store operation before compaction: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
activeStore := store
|
||||
if activeStore == nil {
|
||||
return 0, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
total, err := activeStore.Flush(ctx, now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("flush closed metric minutes: %w", err)
|
||||
}
|
||||
coarse, err := activeStore.FlushCoarse(ctx, now)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("seal closed metric rollups: %w", err)
|
||||
}
|
||||
return total + coarse, nil
|
||||
}
|
||||
|
||||
// CleanupExpired applies retention in a separate, low-frequency operation.
|
||||
// It intentionally shares Compact's gate so delete-heavy maintenance never
|
||||
// overlaps minute or coarse-rollup persistence.
|
||||
func CleanupExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
if !compactOperations.TryAcquire() {
|
||||
return 0, ErrCompactInProgress
|
||||
}
|
||||
defer compactOperations.Release()
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return 0, fmt.Errorf("wait for metric store operation before retention cleanup: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
activeStore := store
|
||||
if activeStore == nil {
|
||||
return 0, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
deleted, err := activeStore.CleanupExpired(ctx, now)
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("clean up expired metric data: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRollupRawRetention documents the fixed in-memory exact-sample
|
||||
// window. Samples older than one minute are losslessly byte-encoded;
|
||||
// older history is served by the persisted rollup ladder.
|
||||
DefaultRollupRawRetention = 10 * time.Minute
|
||||
DefaultRollupFinestTier = time.Minute
|
||||
defaultRollupPointLimit = 600
|
||||
|
||||
defaultRollupMinuteRetentionMinutes = defaultRollupPointLimit
|
||||
defaultRollupFiveMinuteRetentionMinutes = 5 * defaultRollupPointLimit
|
||||
defaultRollupHourRetentionHours = defaultRollupPointLimit
|
||||
defaultRollupDayRetentionDays = 100 * 365
|
||||
)
|
||||
|
||||
// MetricStoreConfig 保存 metric store 配置。
|
||||
//
|
||||
// 注意:metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃)。
|
||||
// 未显式配置时默认使用 SQLite(./data/metrics.db)。
|
||||
type MetricStoreConfig struct {
|
||||
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
|
||||
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
|
||||
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
|
||||
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
|
||||
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
|
||||
// RollupMinuteRetentionMinutes controls the persisted 1-minute bucket window.
|
||||
RollupMinuteRetentionMinutes int `json:"metric_rollup_minute_retention_minutes" default:"600"`
|
||||
// RollupFiveMinuteRetentionMinutes controls the persisted 5-minute bucket window.
|
||||
RollupFiveMinuteRetentionMinutes int `json:"metric_rollup_five_minute_retention_minutes" default:"3000"`
|
||||
// RollupHourRetentionHours controls the persisted 1-hour bucket window.
|
||||
RollupHourRetentionHours int `json:"metric_rollup_hour_retention_hours" default:"600"`
|
||||
}
|
||||
|
||||
// MetricStoreConfigKeys 配置键
|
||||
const (
|
||||
MetricDBDriverKey = "metric_db_driver"
|
||||
MetricDBDSNKey = "metric_db_dsn"
|
||||
MetricTablePrefixKey = "metric_table_prefix"
|
||||
MetricMaxOpenConnsKey = "metric_max_open_conns"
|
||||
MetricMaxIdleConnsKey = "metric_max_idle_conns"
|
||||
MetricRollupMinuteRetentionMinutesKey = "metric_rollup_minute_retention_minutes"
|
||||
MetricRollupFiveMinuteRetentionMinutesKey = "metric_rollup_five_minute_retention_minutes"
|
||||
MetricRollupHourRetentionHoursKey = "metric_rollup_hour_retention_hours"
|
||||
// MigrationTargetKey 记录上一次成功完成手动迁移的目标指纹(driver+dsn),
|
||||
// 用于在下一次管理员手动迁移时推断默认源库。
|
||||
MigrationTargetKey = "metric_migration_target"
|
||||
)
|
||||
|
||||
func targetFingerprint(cfg *MetricStoreConfig) string {
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
dsn := strings.TrimSpace(cfg.DSN)
|
||||
return fmt.Sprintf("%s|%s", driver, dsn)
|
||||
}
|
||||
|
||||
// buildMetricConfig 根据 MetricStoreConfig 构造底层 metric.Config。
|
||||
// autoMigrate 控制是否在 Open 时自动建表:正式初始化/热加载时为 true,
|
||||
// 仅做连接测试时为 false(不写入 schema,避免对目标库产生副作用)。
|
||||
func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config, error) {
|
||||
if cfg == nil {
|
||||
return metric.Config{}, fmt.Errorf("metric store config is nil")
|
||||
}
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
|
||||
tablePrefix := cfg.TablePrefix
|
||||
if tablePrefix == "" {
|
||||
tablePrefix = "metric_"
|
||||
}
|
||||
opts := []metric.Option{
|
||||
metric.WithTablePrefix(tablePrefix),
|
||||
metric.WithAutoMigrate(autoMigrate),
|
||||
}
|
||||
policy, err := rollupPolicyFromConfig(cfg)
|
||||
if err != nil {
|
||||
return metric.Config{}, err
|
||||
}
|
||||
opts = append(opts, metric.WithRollupPolicy(policy))
|
||||
|
||||
switch driver {
|
||||
case metric.DriverSQLite:
|
||||
dsn := cfg.DSN
|
||||
if dsn == "" || dsn == "./data/metrics.db" {
|
||||
// 注意:刻意不使用 cache=shared。SQLite 共享缓存模式使用表级锁,
|
||||
// 当一个连接持有读锁、另一个连接尝试写入时会立即返回
|
||||
// SQLITE_LOCKED("database table is locked"),且 busy_timeout
|
||||
// 对共享缓存的表级锁无效,迁移期间与前台查询/实时写入并发时必然报错。
|
||||
// _txlock=immediate 让写事务开始即获取写锁,避免锁升级死锁。
|
||||
dsn = "file:./data/metrics.db?mode=rwc&_txlock=immediate"
|
||||
} else {
|
||||
// 用户自定义 DSN 时,剥离 cache=shared,避免上述表级锁问题。
|
||||
dsn = stripSharedCache(dsn)
|
||||
}
|
||||
// SQLite 串行化写入:固定单写连接以避免 "database is locked" 竞争,
|
||||
// 同时启用独立的 WAL 只读连接池提升前台查询并发(写仍走单主连接)。
|
||||
// 这里刻意忽略 cfg.MaxOpenConns/MaxIdleConns —— 对 SQLite 而言多写连接
|
||||
// 只会引入锁竞争而非提升吞吐。
|
||||
opts = append(opts, metric.WithMaxOpenConns(1), metric.WithMaxIdleConns(1))
|
||||
opts = append(opts, metric.WithSQLiteReadPool(2))
|
||||
return metric.SQLite(dsn, opts...), nil
|
||||
case metric.DriverMySQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.MySQL(cfg.DSN, opts...), nil
|
||||
case metric.DriverPostgreSQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.PostgreSQL(cfg.DSN, opts...), nil
|
||||
default:
|
||||
return metric.Config{}, fmt.Errorf("unsupported metric database driver: %s", cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRollupPolicy() metric.RollupPolicy {
|
||||
return rollupPolicyFromValues(
|
||||
defaultRollupMinuteRetentionMinutes,
|
||||
defaultRollupFiveMinuteRetentionMinutes,
|
||||
defaultRollupHourRetentionHours,
|
||||
)
|
||||
}
|
||||
|
||||
func rollupPolicyFromConfig(cfg *MetricStoreConfig) (metric.RollupPolicy, error) {
|
||||
if cfg == nil {
|
||||
return metric.RollupPolicy{}, fmt.Errorf("metric store config is nil")
|
||||
}
|
||||
|
||||
minuteRetention := cfg.RollupMinuteRetentionMinutes
|
||||
fiveMinuteRetention := cfg.RollupFiveMinuteRetentionMinutes
|
||||
hourRetention := cfg.RollupHourRetentionHours
|
||||
// Configs constructed by older callers do not have the new fields. Treat
|
||||
// their zero values as omitted so recovery and migration remain compatible.
|
||||
if minuteRetention == 0 {
|
||||
minuteRetention = defaultRollupMinuteRetentionMinutes
|
||||
}
|
||||
if fiveMinuteRetention == 0 {
|
||||
fiveMinuteRetention = defaultRollupFiveMinuteRetentionMinutes
|
||||
}
|
||||
if hourRetention == 0 {
|
||||
hourRetention = defaultRollupHourRetentionHours
|
||||
}
|
||||
if minuteRetention < 0 || fiveMinuteRetention < 0 || hourRetention < 0 {
|
||||
return metric.RollupPolicy{}, fmt.Errorf("metric rollup retention values must be positive integers")
|
||||
}
|
||||
|
||||
minuteDuration, err := rollupDuration(minuteRetention, time.Minute)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
fiveMinuteDuration, err := rollupDuration(fiveMinuteRetention, time.Minute)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
hourDuration, err := rollupDuration(hourRetention, time.Hour)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
|
||||
policy := rollupPolicyFromDurations(minuteDuration, fiveMinuteDuration, hourDuration)
|
||||
if err := policy.Validate(); err != nil {
|
||||
return metric.RollupPolicy{}, fmt.Errorf("invalid metric rollup retention policy: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func rollupPolicyFromValues(minuteRetentionMinutes, fiveMinuteRetentionMinutes, hourRetentionHours int) metric.RollupPolicy {
|
||||
return rollupPolicyFromDurations(
|
||||
time.Duration(minuteRetentionMinutes)*time.Minute,
|
||||
time.Duration(fiveMinuteRetentionMinutes)*time.Minute,
|
||||
time.Duration(hourRetentionHours)*time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
func rollupPolicyFromDurations(minuteRetention, fiveMinuteRetention, hourRetention time.Duration) metric.RollupPolicy {
|
||||
return metric.RollupPolicy{
|
||||
RawRetention: DefaultRollupRawRetention,
|
||||
Tiers: []metric.RollupTier{
|
||||
{Interval: time.Minute, Retention: minuteRetention},
|
||||
{Interval: 5 * time.Minute, Retention: fiveMinuteRetention},
|
||||
{Interval: time.Hour, Retention: hourRetention},
|
||||
// Daily buckets form the terminal tier. They remain available until
|
||||
// the metric's own retention policy removes them.
|
||||
{Interval: 24 * time.Hour, Retention: time.Duration(defaultRollupDayRetentionDays) * 24 * time.Hour},
|
||||
},
|
||||
Compression: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func rollupDuration(value int, unit time.Duration) (time.Duration, error) {
|
||||
maxDurationValue := int64((time.Duration(1<<63 - 1)) / unit)
|
||||
if value <= 0 || int64(value) > maxDurationValue {
|
||||
return 0, fmt.Errorf("metric rollup retention value must be a positive duration")
|
||||
}
|
||||
return time.Duration(value) * unit, nil
|
||||
}
|
||||
|
||||
// ResolveDriverFromConfig 根据 DSN 自动推断 metrics 数据库类型;当 DSN 不能可靠
|
||||
// 识别时回退到旧配置中的 driver,以兼容已有配置和非常规 DSN。
|
||||
func ResolveDriverFromConfig(configuredDriver, dsn string) metric.Driver {
|
||||
if driver, ok := InferDriverFromDSN(dsn); ok {
|
||||
return driver
|
||||
}
|
||||
|
||||
switch driver := metric.Driver(strings.ToLower(strings.TrimSpace(configuredDriver))); driver {
|
||||
case metric.DriverSQLite, metric.DriverMySQL, metric.DriverPostgreSQL:
|
||||
return driver
|
||||
default:
|
||||
return metric.DriverSQLite
|
||||
}
|
||||
}
|
||||
|
||||
// InferDriverFromDSN 尽量根据常见 DSN 格式推断数据库类型。
|
||||
// 返回 ok=false 表示格式不够明确,调用方应使用已有配置作为兜底。
|
||||
func InferDriverFromDSN(dsn string) (metric.Driver, bool) {
|
||||
raw := strings.TrimSpace(dsn)
|
||||
if raw == "" {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
|
||||
// PostgreSQL URL DSN: postgres://... 或 postgresql://...
|
||||
if strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://") {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// SQLite 常见文件/内存 DSN。
|
||||
if raw == ":memory:" || strings.HasPrefix(lower, "file:") || strings.HasPrefix(lower, "sqlite://") || strings.HasPrefix(lower, "sqlite3://") {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
// MySQL URL(虽然 go-sql-driver/mysql 原生 DSN 通常不是 URL,但这里用于给出
|
||||
// 类型推断;连接测试仍会校验 DSN 是否可被驱动接受)。
|
||||
if strings.HasPrefix(lower, "mysql://") {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// PostgreSQL 关键字/值 DSN: host=... user=... dbname=...
|
||||
if looksLikePostgreSQLKeyValueDSN(lower) {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// go-sql-driver/mysql DSN: user:pass@tcp(host:3306)/db、user@unix(...)/db、user:pass@/db 等。
|
||||
if looksLikeMySQLDSN(lower) {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// SQLite 路径:./data/metrics.db、/var/lib/metrics.sqlite3、metrics.sqlite 等。
|
||||
if looksLikeSQLitePath(lower) {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func looksLikePostgreSQLKeyValueDSN(lower string) bool {
|
||||
if !strings.Contains(lower, "=") || strings.Contains(lower, "://") {
|
||||
return false
|
||||
}
|
||||
keys := []string{"host=", "user=", "password=", "dbname=", "port=", "sslmode="}
|
||||
matched := 0
|
||||
for _, key := range keys {
|
||||
if strings.Contains(lower, key) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
// dbname= 基本是 PostgreSQL libpq DSN 的强特征;否则至少匹配两个常见键。
|
||||
return strings.Contains(lower, "dbname=") || matched >= 2
|
||||
}
|
||||
|
||||
func looksLikeMySQLDSN(lower string) bool {
|
||||
if strings.Contains(lower, "://") || strings.Contains(lower, " ") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "@tcp(") || strings.Contains(lower, "@unix(") || strings.Contains(lower, "@/") {
|
||||
return true
|
||||
}
|
||||
// user:pass@host/db、user@host/db 这类虽然不是推荐格式,但也明显偏 MySQL。
|
||||
return strings.Contains(lower, "@") && strings.Contains(lower, "/")
|
||||
}
|
||||
|
||||
func looksLikeSQLitePath(lower string) bool {
|
||||
path := lower
|
||||
if idx := strings.IndexAny(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
return strings.HasSuffix(path, ".db") || strings.HasSuffix(path, ".sqlite") || strings.HasSuffix(path, ".sqlite3")
|
||||
}
|
||||
|
||||
// stripSharedCache 从 SQLite DSN 中移除 cache=shared 参数,避免共享缓存模式下的
|
||||
// 表级锁(SQLITE_LOCKED "database table is locked")。其它参数保持不变。
|
||||
func stripSharedCache(dsn string) string {
|
||||
if !strings.Contains(dsn, "cache=shared") {
|
||||
return dsn
|
||||
}
|
||||
idx := strings.Index(dsn, "?")
|
||||
if idx < 0 {
|
||||
return dsn
|
||||
}
|
||||
base := dsn[:idx]
|
||||
query := dsn[idx+1:]
|
||||
parts := strings.Split(query, "&")
|
||||
kept := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p == "cache=shared" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "?" + strings.Join(kept, "&")
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
const defaultBuiltinMetricRetentionDays = 1
|
||||
|
||||
// RetentionSummary is the compatibility view of all persisted metric policies.
|
||||
type RetentionSummary struct {
|
||||
AllPositive bool
|
||||
MaxDays int
|
||||
}
|
||||
|
||||
// GetRetentionSummary aggregates the active store's metric definitions. An
|
||||
// empty definition set is not considered record-enabled.
|
||||
func GetRetentionSummary(ctx context.Context) (RetentionSummary, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return RetentionSummary{}, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return RetentionSummary{}, err
|
||||
}
|
||||
return summarizeRetentionDefinitions(defs), nil
|
||||
}
|
||||
|
||||
func summarizeRetentionDefinitions(defs []metric.Definition) RetentionSummary {
|
||||
if len(defs) == 0 {
|
||||
return RetentionSummary{}
|
||||
}
|
||||
summary := RetentionSummary{AllPositive: true}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays <= 0 {
|
||||
summary.AllPositive = false
|
||||
}
|
||||
if def.RetentionDays > summary.MaxDays {
|
||||
summary.MaxDays = def.RetentionDays
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// createMetricDefinitions creates built-in definitions with explicit policies.
|
||||
func createMetricDefinitions(ctx context.Context, s *metric.Store) error {
|
||||
return createMetricDefinitionsWithDefaultRetention(ctx, s, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
// EnsureBuiltinMetricDefinitions registers definitions for the server's
|
||||
// built-in report and ping writers before a standalone Store receives points.
|
||||
func EnsureBuiltinMetricDefinitions(ctx context.Context, s *metric.Store) error {
|
||||
return createMetricDefinitions(ctx, s)
|
||||
}
|
||||
|
||||
func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.Store, defaultRetentionDays int) error {
|
||||
if defaultRetentionDays < defaultBuiltinMetricRetentionDays {
|
||||
defaultRetentionDays = defaultBuiltinMetricRetentionDays
|
||||
}
|
||||
definitions := []metric.Definition{
|
||||
{Name: MetricCPU, Type: metric.TypeGauge, Unit: "%", Description: "CPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPU, Type: metric.TypeGauge, Unit: "%", Description: "GPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUDeviceUsage, Type: metric.TypeGauge, Unit: "%", Description: "Per-device GPU utilization", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMem, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMemTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUTemp, Type: metric.TypeGauge, Unit: "°C", Description: "GPU temperature", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricRAM, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricSwap, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricLoad, Type: metric.TypeGauge, Unit: "", Description: "System load average", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricDisk, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetIn, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network in rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetOut, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network out rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalUp, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total upload", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalDown, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total download", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficUp, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic upload delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficDown, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic download delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricProcess, Type: metric.TypeGauge, Unit: "count", Description: "Process count", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnections, Type: metric.TypeGauge, Unit: "count", Description: "TCP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnectionsUDP, Type: metric.TypeGauge, Unit: "count", Description: "UDP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLatency, Type: metric.TypeGauge, Unit: "ms", Description: "Ping latency", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLoss, Type: metric.TypeGauge, Unit: "ratio", Description: "Ping packet loss indicator", RetentionDays: defaultRetentionDays},
|
||||
}
|
||||
|
||||
for _, def := range definitions {
|
||||
existing, err := s.GetMetric(ctx, def.Name)
|
||||
if err != nil && !errors.Is(err, metric.ErrNotFound) {
|
||||
return fmt.Errorf("failed to get metric %s: %w", def.Name, err)
|
||||
}
|
||||
if err == nil {
|
||||
if existing.RetentionDays == 0 {
|
||||
continue
|
||||
}
|
||||
def.RetentionDays = existing.RetentionDays
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, def); err != nil {
|
||||
return fmt.Errorf("failed to create metric %s: %w", def.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// farFuture 返回一个足够远的未来时间,用于以 DeleteBefore 语义清空某指标的全部数据。
|
||||
func farFuture() time.Time {
|
||||
return time.Now().UTC().Add(24 * 365 * time.Hour)
|
||||
}
|
||||
|
||||
// DeleteAllRecords 删除所有负载/系统类记录(保留指标定义,不含 ping)。
|
||||
func DeleteAllRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
for _, metricName := range recordMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete metric %s: %v", metricName, err)
|
||||
}
|
||||
}
|
||||
clearReportTrafficStates()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllPingRecords 删除全部 ping 记录(保留指标定义)。
|
||||
func DeleteAllPingRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePingRecordsByTask 删除指定任务(task_id)的全部 ping 记录。
|
||||
func DeletePingRecordsByTask(ctx context.Context, taskIDs []uint) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, id := range taskIDs {
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteSeries(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
Tags: map[string]string{"task_id": fmt.Sprintf("%d", id)},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records for task %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEntity 删除指定 agent 在所有指标下的历史数据。
|
||||
func DeleteEntity(ctx context.Context, entityID string) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
if _, err := s.DeleteEntity(ctx, entityID); err != nil {
|
||||
return fmt.Errorf("failed to delete metric records for entity %s: %w", entityID, err)
|
||||
}
|
||||
deleteReportTrafficState(entityID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEntityAsync clears one agent's metric history without delaying the
|
||||
// client deletion response.
|
||||
func DeleteEntityAsync(entityID string) {
|
||||
go func() {
|
||||
if err := DeleteEntity(context.Background(), entityID); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete metric records for entity %s: %v", entityID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// DeleteMetricDataAsync clears disabled metric history without delaying an
|
||||
// admin retention-policy update response.
|
||||
func DeleteMetricDataAsync(metricName string) {
|
||||
go func() {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
logger.Errorf("metricstore", "Failed to delete disabled metric %s: metric store not enabled", metricName)
|
||||
return
|
||||
}
|
||||
if _, err := s.DeleteMetricDataIfDisabled(context.Background(), metricName); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete disabled metric %s: %v", metricName, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record
|
||||
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordsByClientAndTimeFromSeries(ctx, s, clientUUID, start, end)
|
||||
}
|
||||
|
||||
// GetRecordMetricMaxByClientAndTime 查询单项监控指标在各时间桶内的最大值。
|
||||
func GetRecordMetricMaxByClientAndTime(ctx context.Context, clientUUID, recordMetric string, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordMetricMaxByClientAndTimeFromSeries(ctx, s, clientUUID, recordMetric, start, end)
|
||||
}
|
||||
|
||||
// GetRecordsByTime 从 metric store 查询所有客户端在时间范围内的记录
|
||||
func GetRecordsByTime(ctx context.Context, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
interval := recordSeriesInterval(s, start, end, time.Now().UTC())
|
||||
entityIDs, err := listRecordEntityIDs(ctx, s, start, end, interval)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []models.Record
|
||||
for _, entityID := range entityIDs {
|
||||
items, err := getRecordsByClientAndTimeFromSeries(ctx, s, entityID, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, items...)
|
||||
}
|
||||
sortRecords(records)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
type recordSeriesKey struct {
|
||||
client string
|
||||
ts int64
|
||||
}
|
||||
|
||||
func getRecordsByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time) ([]models.Record, error) {
|
||||
now := time.Now().UTC()
|
||||
interval := recordSeriesInterval(s, start, end, now)
|
||||
recordMap := make(map[recordSeriesKey]*models.Record)
|
||||
|
||||
for _, metricName := range loadRecordMetricNames {
|
||||
points, err := s.Series(ctx, metric.AggregateQuery{
|
||||
Query: metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: clientUUID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
},
|
||||
Aggregation: recordMetricAggregation(metricName),
|
||||
Interval: interval,
|
||||
}, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
|
||||
}
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
key := recordSeriesKey{client: entityID, ts: point.Bucket.Unix()}
|
||||
if recordMap[key] == nil {
|
||||
recordMap[key] = &models.Record{
|
||||
Client: entityID,
|
||||
Time: point.Bucket.UTC(),
|
||||
}
|
||||
}
|
||||
applyRecordMetricValue(recordMap[key], metricName, point.Value)
|
||||
}
|
||||
}
|
||||
|
||||
records := make([]models.Record, 0, len(recordMap))
|
||||
for _, rec := range recordMap {
|
||||
records = append(records, *rec)
|
||||
}
|
||||
sortRecords(records)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func getRecordMetricMaxByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID, recordMetric string, start, end time.Time) ([]models.Record, error) {
|
||||
metricName, ok := metricNameForRecordField(recordMetric)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported record metric %q", recordMetric)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
points, err := s.Series(ctx, metric.AggregateQuery{
|
||||
Query: metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: clientUUID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
},
|
||||
Aggregation: metric.AggMax,
|
||||
Interval: recordSeriesInterval(s, start, end, now),
|
||||
}, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
|
||||
}
|
||||
|
||||
records := make([]models.Record, 0, len(points))
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
record := models.Record{Client: entityID, Time: point.Bucket.UTC()}
|
||||
applyRecordMetricValue(&record, metricName, point.Value)
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func recordMetricAggregation(metricName string) metric.Aggregation {
|
||||
switch metricName {
|
||||
case MetricTrafficUp, MetricTrafficDown:
|
||||
return metric.AggSum
|
||||
case MetricNetTotalUp, MetricNetTotalDown:
|
||||
return metric.AggLast
|
||||
default:
|
||||
return metric.AggAvg
|
||||
}
|
||||
}
|
||||
|
||||
func recordSeriesInterval(s *metric.Store, start, end, now time.Time) time.Duration {
|
||||
interval := recordDownsampleInterval(end.Sub(start), 500)
|
||||
return s.CompatibleSeriesInterval(start, now, interval)
|
||||
}
|
||||
|
||||
func recordDownsampleInterval(rangeDuration time.Duration, maxPoints int) time.Duration {
|
||||
if maxPoints <= 0 {
|
||||
maxPoints = 500
|
||||
}
|
||||
nanos := rangeDuration.Nanoseconds()
|
||||
if nanos <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
interval := time.Duration((nanos + int64(maxPoints) - 1) / int64(maxPoints))
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
return metric.FloorStandardInterval(interval)
|
||||
}
|
||||
|
||||
func listRecordEntityIDs(ctx context.Context, s *metric.Store, start, end time.Time, interval time.Duration) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
for _, metricName := range loadRecordMetricNames {
|
||||
ids, err := s.EntityIDs(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
Start: start.Add(-interval),
|
||||
End: end,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyRecordMetricValue(rec *models.Record, metricName string, value float64) {
|
||||
switch metricName {
|
||||
case MetricCPU:
|
||||
rec.Cpu = float32(value)
|
||||
case MetricGPU:
|
||||
rec.Gpu = float32(value)
|
||||
case MetricRAM:
|
||||
rec.Ram = int64(value)
|
||||
case MetricSwap:
|
||||
rec.Swap = int64(value)
|
||||
case MetricLoad:
|
||||
rec.Load = float32(value)
|
||||
case MetricDisk:
|
||||
rec.Disk = int64(value)
|
||||
case MetricNetIn:
|
||||
rec.NetIn = int64(value)
|
||||
case MetricNetOut:
|
||||
rec.NetOut = int64(value)
|
||||
case MetricNetTotalUp:
|
||||
rec.NetTotalUp = int64(value)
|
||||
case MetricNetTotalDown:
|
||||
rec.NetTotalDown = int64(value)
|
||||
case MetricTrafficUp:
|
||||
rec.TrafficUp = int64(value)
|
||||
case MetricTrafficDown:
|
||||
rec.TrafficDown = int64(value)
|
||||
case MetricProcess:
|
||||
rec.Process = int(value)
|
||||
case MetricConnections:
|
||||
rec.Connections = int(value)
|
||||
case MetricConnectionsUDP:
|
||||
rec.ConnectionsUdp = int(value)
|
||||
}
|
||||
}
|
||||
|
||||
func sortRecords(records []models.Record) {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
if records[i].Client != records[j].Client {
|
||||
return records[i].Client < records[j].Client
|
||||
}
|
||||
return records[i].Time.Before(records[j].Time)
|
||||
})
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 从 metric store 查询 GPU 记录
|
||||
func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
// 查询 GPU 相关指标(每设备利用率使用独立指标 gpu.device.usage)
|
||||
gpuMetrics := []string{MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp}
|
||||
|
||||
// 按设备索引和时间组织数据
|
||||
type gpuKey struct {
|
||||
deviceIndex int
|
||||
timestamp int64
|
||||
}
|
||||
recordMap := make(map[gpuKey]*models.GPURecord)
|
||||
|
||||
for _, metricName := range gpuMetrics {
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: clientUUID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
continue // GPU 数据可能不存在
|
||||
}
|
||||
|
||||
for _, p := range points {
|
||||
deviceIndex := 0
|
||||
deviceName := ""
|
||||
if idx, ok := p.Tags["device_index"]; ok {
|
||||
fmt.Sscanf(idx, "%d", &deviceIndex)
|
||||
}
|
||||
if name, ok := p.Tags["device_name"]; ok {
|
||||
deviceName = name
|
||||
}
|
||||
|
||||
key := gpuKey{deviceIndex: deviceIndex, timestamp: p.Timestamp.Unix()}
|
||||
if recordMap[key] == nil {
|
||||
recordMap[key] = &models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: p.Timestamp.UTC(),
|
||||
DeviceIndex: deviceIndex,
|
||||
DeviceName: deviceName,
|
||||
}
|
||||
}
|
||||
rec := recordMap[key]
|
||||
|
||||
switch metricName {
|
||||
case MetricGPUDeviceUsage:
|
||||
rec.Utilization = float32(p.Value)
|
||||
case MetricGPUMem:
|
||||
rec.MemUsed = int64(p.Value)
|
||||
case MetricGPUMemTotal:
|
||||
rec.MemTotal = int64(p.Value)
|
||||
case MetricGPUTemp:
|
||||
rec.Temperature = int(p.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
records := make([]models.GPURecord, 0, len(recordMap))
|
||||
for _, rec := range recordMap {
|
||||
records = append(records, *rec)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetPingRecords 从 metric store 查询兼容旧接口的 ping 记录。
|
||||
//
|
||||
// 旧接口过去直接读取 ping_records。这里使用与 queryMetrics 相同的 Series
|
||||
@@ -0,0 +1,114 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStoreNotInitialized = errors.New("metric store not initialized")
|
||||
ErrStoreBusy = errors.New("metric store is busy")
|
||||
)
|
||||
|
||||
// StorageInfo describes the physical storage owned by the active metric store.
|
||||
// Size remains useful even when the store points at an external database because
|
||||
// pkg/metric limits its query to the three tables managed by this Store.
|
||||
type StorageInfo struct {
|
||||
Driver metric.Driver
|
||||
Action metric.MaintenanceAction
|
||||
Size int64
|
||||
}
|
||||
|
||||
// MaintenanceResult keeps measurement failures separate from the maintenance
|
||||
// error. A database may allow table maintenance while denying catalog queries,
|
||||
// and callers should still be able to report that the operation succeeded.
|
||||
type MaintenanceResult struct {
|
||||
Driver metric.Driver
|
||||
Action metric.MaintenanceAction
|
||||
Before int64
|
||||
After int64
|
||||
BeforeSizeError error
|
||||
AfterSizeError error
|
||||
}
|
||||
|
||||
// InspectStorage reads physical storage information while preventing a store
|
||||
// reload from closing the active connection underneath the query.
|
||||
func InspectStorage(ctx context.Context) (StorageInfo, error) {
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return StorageInfo{}, fmt.Errorf("wait for metric store operations before inspection: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return StorageInfo{}, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
info := StorageInfo{
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
size, err := activeStore.StorageSize(ctx)
|
||||
info.Size = size
|
||||
return info, err
|
||||
}
|
||||
|
||||
// ReclaimSpace performs the driver-specific physical maintenance operation.
|
||||
// It takes the exclusive operation lock, so a table/file rewrite cannot run
|
||||
// concurrently with report writes or compaction.
|
||||
func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
|
||||
// Reclamation is intentionally non-cancellable. It waits for all active
|
||||
// operations, then keeps the store gate until digest conversion, VACUUM,
|
||||
// and the final checkpoint have completed.
|
||||
_ = ctx
|
||||
if err := storeOperations.Acquire(context.Background()); err != nil {
|
||||
return MaintenanceResult{}, err
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
result := MaintenanceResult{
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
maintenanceCtx := context.Background()
|
||||
result.Before, result.BeforeSizeError = activeStore.StorageSize(maintenanceCtx)
|
||||
maintenanceErr := deleteUndefinedMetrics(maintenanceCtx, activeStore)
|
||||
if maintenanceErr == nil {
|
||||
maintenanceErr = activeStore.ReclaimSpace(maintenanceCtx)
|
||||
}
|
||||
result.After, result.AfterSizeError = activeStore.StorageSize(maintenanceCtx)
|
||||
return result, maintenanceErr
|
||||
}
|
||||
|
||||
func deleteUndefinedMetrics(ctx context.Context, s *metric.Store) error {
|
||||
definitions, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list metric definitions before reclaim: %w", err)
|
||||
}
|
||||
|
||||
builtin := make(map[string]struct{}, len(builtinMetricNames))
|
||||
for _, name := range builtinMetricNames {
|
||||
builtin[name] = struct{}{}
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if _, ok := builtin[definition.Name]; ok {
|
||||
continue
|
||||
}
|
||||
if err := s.DeleteMetric(ctx, definition.Name); err != nil {
|
||||
return fmt.Errorf("delete undefined metric %q before reclaim: %w", definition.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user