Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cf0f79d8 | ||
|
|
9e5f9a3a71 | ||
|
|
70bbceaf09 | ||
|
|
e30b9fdbd4 | ||
|
|
d5689efeea | ||
|
|
c24c4b9c7c | ||
|
|
d50cc0a9fd | ||
|
|
528cea23be | ||
|
|
b7042672db | ||
|
|
7468dcb12b | ||
|
|
dc7f4cb541 | ||
|
|
48af63ca7b | ||
|
|
3cf8445ad4 | ||
|
|
b38b0b2347 | ||
|
|
fa5a04d8e2 | ||
|
|
4b24f1e196 | ||
|
|
5006f55ca0 | ||
|
|
1b76b7ad5b | ||
|
|
13cbdca545 | ||
|
|
e48aa1e769 | ||
|
|
a68704a425 | ||
|
|
39cb3f59b2 | ||
|
|
9812acfd11 | ||
|
|
70df32096f | ||
|
|
29d309f4a1 | ||
|
|
7d692d2faf | ||
|
|
68c284d043 | ||
|
|
14bc87d1e9 | ||
|
|
154d2b8af2 | ||
|
|
2c3f2281fa | ||
|
|
7e91794f24 | ||
|
|
f57461f87f | ||
|
|
d15f0512c7 | ||
|
|
4ec22edf1f | ||
|
|
0ca87aafd1 | ||
|
|
f18ad72c17 | ||
|
|
8e4a3f8709 | ||
|
|
80add06a12 | ||
|
|
1333d63ce0 | ||
|
|
44ed3035f1 | ||
|
|
7ec36157d5 | ||
|
|
360fd5f50b | ||
|
|
b11ffd3aa7 | ||
|
|
d7d6135c49 | ||
|
|
ac037f2177 | ||
|
|
5405bdf377 | ||
|
|
f754f01f53 | ||
|
|
434ba108e1 | ||
|
|
24254749b5 | ||
|
|
c6b02af000 | ||
|
|
b28ac9d42c | ||
|
|
2ff7c11db0 | ||
|
|
46fd2af44c | ||
|
|
4d2eb70c62 | ||
|
|
7a6692b79a | ||
|
|
63d78b6ed4 | ||
|
|
2319ea77f6 | ||
|
|
0c39a52e0b | ||
|
|
e31a032d89 | ||
|
|
da4d5187c1 | ||
|
|
bf6b45ec3a | ||
|
|
4a3400bee6 | ||
|
|
4caa067556 | ||
|
|
2f3959adde | ||
|
|
63a7ffddcf | ||
|
|
92d679ffdb | ||
|
|
7c30fc4dc9 | ||
|
|
773a3715dc | ||
|
|
75d7b6c7a3 | ||
|
|
c294c17642 | ||
|
|
2883bf6295 | ||
|
|
14bf8bde7c | ||
|
|
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 | ||
|
|
433bc52788 | ||
|
|
6aeb444b2b | ||
|
|
bbf631714d | ||
|
|
c204094173 | ||
|
|
417cec5d44 | ||
|
|
5401b4c10e | ||
|
|
f1cfbd9391 | ||
|
|
ae6f9a7840 | ||
|
|
a75ac3e97d | ||
|
|
00fa79e31c | ||
|
|
392b05be94 | ||
|
|
26e120cc89 | ||
|
|
413dd780f2 | ||
|
|
3d26743492 | ||
|
|
0a6927805e | ||
|
|
d16889c103 | ||
|
|
7894990d91 | ||
|
|
fe3afceca9 | ||
|
|
014ee67d69 | ||
|
|
ec4dee765a | ||
|
|
00d8936455 | ||
|
|
4b97ae92da | ||
|
|
a33885255c | ||
|
|
fd8a4c4895 | ||
|
|
039fd6b8d9 | ||
|
|
09c346180e | ||
|
|
179606ea6e | ||
|
|
2118648a58 | ||
|
|
db26327140 | ||
|
|
5132b60f18 | ||
|
|
43cfbca62f | ||
|
|
1cc6f22a5a | ||
|
|
dfc242dc0e | ||
|
|
f1d17d9bcd | ||
|
|
403879e705 | ||
|
|
cbc91cfb0c | ||
|
|
b709ac9e98 | ||
|
|
6d29b165ca | ||
|
|
44b1106dec | ||
|
|
2bb96b7272 | ||
|
|
2cd1159f94 |
No files matched your search
@@ -0,0 +1,74 @@
|
||||
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: Install zstd
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if ! command -v zstd >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
fi
|
||||
|
||||
- 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
|
||||
rm -rf web/public/defaultTheme/dist web/public/defaultTheme/dist.tar.zst
|
||||
test -f komari-web/dist/index.html
|
||||
tar -cf "$RUNNER_TEMP/komari-default-dist.tar" -C komari-web/dist .
|
||||
zstd -19 -T0 -q -f "$RUNNER_TEMP/komari-default-dist.tar" -o web/public/defaultTheme/dist.tar.zst
|
||||
rm -f "$RUNNER_TEMP/komari-default-dist.tar"
|
||||
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,25 @@
|
||||
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
|
||||
test -s web/public/defaultTheme/dist.tar.zst
|
||||
@@ -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,111 @@
|
||||
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 }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -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,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
@@ -40,7 +28,7 @@ jobs:
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION_SHA="${{ github.sha }}"
|
||||
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
|
||||
go build -ldflags "-s -w -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -69,7 +57,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 -extldflags=-Wl,--strip-all -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,163 @@
|
||||
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)"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -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 -extldflags=-Wl,--strip-all -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
|
||||
+141
-29
@@ -1,4 +1,4 @@
|
||||
name: Build and Attach Binaries to Release
|
||||
name: Attach Release Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -13,70 +13,163 @@ 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 }}"
|
||||
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
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -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 +177,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,297 @@
|
||||
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
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -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
|
||||
+10
@@ -7,12 +7,15 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
__debug*
|
||||
|
||||
# local development data
|
||||
komari.db
|
||||
/data
|
||||
/utils/geoip/data
|
||||
.vscode/
|
||||
/backup
|
||||
/cache
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -29,3 +32,10 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
/dist/
|
||||
.codex
|
||||
.gocache/
|
||||
/web/filemanager/data
|
||||
+10
-35
@@ -1,44 +1,19 @@
|
||||
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 --chmod=755 komari-${TARGETOS}-${TARGETARCH} /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
|
||||
ENV GODEBUG=disablethp=1
|
||||
|
||||
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,122 +1,68 @@
|
||||
|
||||
# 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. [Get started for just ¥5/month. Deploy now!](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
|
||||
| <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://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · Rotating residential proxies from $0.20/GB. Sign up for 500MB of free traffic and try it today!](https://mooproxy.net/?utm_source=komari) |
|
||||
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [Self-Hosted Private CDN \| Subscription-Based CDN-Like Service \| A Fully Controlled, Flexible, Modular CDN Network](https://axisnow.io/zh?utm=komari) |
|
||||
| <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.
|
||||
|
||||
### 我为什么做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>
|
||||
|
||||
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
|
||||
## Support the Project
|
||||
|
||||
> @古xx斯
|
||||
> 哪吒?
|
||||
> > 数据至少保存15天
|
||||
|
||||
> @sxxu
|
||||
> 怕不是做梦的预算
|
||||
|
||||
> @Vxxn
|
||||
> prometheus加grafana
|
||||
> > 部署太麻烦
|
||||
|
||||
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
|
||||
If Komari has been useful to you, consider buying me a coffee. Thank you for your support!
|
||||
|
||||
| TRON Network |
|
||||
| ------------------------------------------------------------------------------------------- |
|
||||
|  |
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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,并采用按小时灵活计费。[每月5元,立即部署](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
|
||||
| <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://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · 动态住宅代理IP低至 $0.20/GB,注册即送 500MB 免费流量,立即体验!](https://mooproxy.net/?utm_source=komari) |
|
||||
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [自建私有部署CDN \| 订阅式高仿CDN \| 自主可控、灵活组合的CDN网络](https://axisnow.io/zh?utm=komari) |
|
||||
| <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,31 +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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Komari is a single user system
|
||||
_, err = accounts.GetSession(session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Unauthorized."})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
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
|
||||
}
|
||||
@@ -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,126 +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/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", "error": 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", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
|
||||
}
|
||||
|
||||
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", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid or missing UUID"})
|
||||
return
|
||||
}
|
||||
req["uuid"] = uuid
|
||||
err := clients.SaveClient(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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",
|
||||
"error": "Failed to delete Record" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
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",
|
||||
"error": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"status": "error",
|
||||
"error": 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", "error": 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",
|
||||
"error": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := clients.GetClientTokenByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"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 {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"error": "Invalid or missing request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
for uuid, weight := range req {
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to update client weight",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
"message": "Client weight updated successfully",
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSessions(c *gin.Context) {
|
||||
|
||||
ss, err := accounts.GetAllSessions()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to get sessions",
|
||||
})
|
||||
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 {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"error": "Invalid or missing session",
|
||||
})
|
||||
return
|
||||
}
|
||||
err := accounts.DeleteSession(req.Session)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to delete session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
"message": "Session deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAllSession(c *gin.Context) {
|
||||
|
||||
err := accounts.DeleteAllSessions()
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to delete session",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
"message": "Session deleted successfully",
|
||||
})
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"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)
|
||||
c.JSON(200, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
}
|
||||
c.JSON(200, cst)
|
||||
}
|
||||
|
||||
// EditSettings 更新自定义配置
|
||||
func EditSettings(c *gin.Context) {
|
||||
cfg := make(map[string]interface{})
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"error": "Bad Request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
@@ -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,48 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
)
|
||||
|
||||
func EstablishConnection(c *gin.Context) {
|
||||
session_id := c.Query("id")
|
||||
session, exists := api.TerminalSessions[session_id]
|
||||
if !exists || session == nil || session.Browser == nil {
|
||||
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
|
||||
return
|
||||
}
|
||||
// Upgrade the connection to WebSocket
|
||||
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 // TODO: 检查源站
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
api.TerminalSessionsMutex.Lock()
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
delete(api.TerminalSessions, session_id)
|
||||
api.TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
session.Agent = conn
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
delete(api.TerminalSessions, session_id)
|
||||
// 通知 Browser 关闭终端连接
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go api.ForwardTerminal(session_id)
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"error": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, Records[uuid])
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if uuid, success := accounts.CheckPassword(data.Username, data.Password); success {
|
||||
|
||||
session, err := accounts.CreateSession(uuid, 2592000)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "Failed to create session" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
c.JSON(200, gin.H{"set-cookie": map[string]interface{}{"session_token": session}})
|
||||
return
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "Invalid credentials"})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
)
|
||||
|
||||
func Logout(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
accounts.DeleteSession(session)
|
||||
c.SetCookie("session_token", "", -1, "/", "", false, true)
|
||||
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})
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
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 {
|
||||
c.JSON(500, gin.H{"status": "error", "error": 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 = ""
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success", "data": clientList})
|
||||
}
|
||||
@@ -1,94 +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/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", "error": "Binding failed"})
|
||||
return
|
||||
}
|
||||
err = accounts.BindingExternalAccount(user.UUID, sso_id)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": "Binding failed"})
|
||||
return
|
||||
}
|
||||
c.Redirect(302, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取用户
|
||||
user, err := accounts.GetUserBySSO(sso_id)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "error",
|
||||
"error": "please log in and bind your GitHub account first.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
session, err := accounts.CreateSession(user.UUID, 2592000)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置cookie并返回
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,27 +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 {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"status": "success",
|
||||
"data": gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
"desc": cst.Description,
|
||||
"custom_head": cst.CustomHead,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"allow_cros": cst.AllowCros,
|
||||
},
|
||||
})
|
||||
}
|
||||
-158
@@ -1,158 +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/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func RequestTerminal(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
_, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"error": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 建立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 // TODO: 检查源站
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 新建一个终端连接
|
||||
id := utils.GenerateRandomString(32)
|
||||
session := &TerminalSession{
|
||||
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!"))
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
session, exists := TerminalSessions[id]
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
func GetVersion(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"version": utils.CurrentVersion,
|
||||
"hash": utils.VersionHash,
|
||||
"status": "success",
|
||||
})
|
||||
}
|
||||
+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,28 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Use: "permit-login",
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd.Println("Password login has been permitted.")
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(PermitPasswordLoginCmd)
|
||||
}
|
||||
+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")
|
||||
}
|
||||
+95
-161
@@ -1,24 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
appserver "github.com/komari-monitor/komari/internal/server"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
frontendpublic "github.com/komari-monitor/komari/web/public"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"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/models"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -27,167 +17,111 @@ var ServerCmd = &cobra.Command{
|
||||
Short: "Start the server",
|
||||
Long: `Start the server`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
InitDatabase()
|
||||
|
||||
go DoRecordsWork()
|
||||
|
||||
r := gin.Default()
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to get config:", err)
|
||||
}
|
||||
|
||||
if cfg.AllowCros {
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
}
|
||||
|
||||
if cfg.GeoIpEnabled {
|
||||
geoip.InitGeoIp()
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour * 24)
|
||||
for range ticker.C {
|
||||
geoip.UpdateGeoIpDatabase()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
|
||||
go func() {
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to get config:", err)
|
||||
}
|
||||
public.UpdateIndex(cfg)
|
||||
}()
|
||||
|
||||
r.Run(flags.Listen)
|
||||
|
||||
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() {
|
||||
if err := frontendpublic.PrepareDefaultDist(); err != nil {
|
||||
logger.Fatalf("server", "prepare default frontend failed: %v", err)
|
||||
}
|
||||
|
||||
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 DoRecordsWork() {
|
||||
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()
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 其余初始化阶段:任一步失败都不应继续对外服务。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
var (
|
||||
TwoFactorIssuer = "Komari Monitor"
|
||||
)
|
||||
|
||||
func Generate2Fa() (string, image.Image, error) {
|
||||
otp, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: TwoFactorIssuer,
|
||||
AccountName: "komari",
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
img, err := otp.Image(250, 250)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return otp.Secret(), img, nil
|
||||
}
|
||||
|
||||
func Enable2Fa(uuid, secret string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
|
||||
}
|
||||
|
||||
func Verify2Fa(uuid, code string) (bool, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var user models.User
|
||||
if err := db.Where("uuid = ?", uuid).First(&user).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 用户未启用2FA时视为校验失败
|
||||
return user.TwoFactor != "" && totp.Validate(code, user.TwoFactor), nil
|
||||
}
|
||||
|
||||
func Disable2Fa(uuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
|
||||
}
|
||||
@@ -4,27 +4,22 @@ 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"
|
||||
|
||||
// CheckPassword 检查密码是否正确
|
||||
//
|
||||
// 如果密码正确,返回用户的 UUID 和 true;否则返回空字符串和 false
|
||||
func CheckPassword(username, passwd string) (uuid string, success bool) {
|
||||
// CheckPassword 检查密码,正确时返回用户 UUID 和 true
|
||||
func CheckPassword(username, passwd string) (uuid string, ok bool) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var user models.User
|
||||
result := db.Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
// 静默处理错误,不显示日志
|
||||
return "", false
|
||||
}
|
||||
if hashPasswd(passwd) != user.Passwd {
|
||||
@@ -34,7 +29,7 @@ func CheckPassword(username, passwd string) (uuid string, success bool) {
|
||||
}
|
||||
|
||||
// ForceResetPassword 强制重置用户密码
|
||||
func ForceResetPassword(username, passwd string) (err error) {
|
||||
func ForceResetPassword(username, passwd string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&models.User{}).Where("username = ?", username).Update("passwd", hashPasswd(passwd))
|
||||
if result.Error != nil {
|
||||
@@ -48,107 +43,80 @@ func ForceResetPassword(username, passwd string) (err error) {
|
||||
|
||||
// hashPasswd 对密码进行加盐哈希
|
||||
func hashPasswd(passwd string) string {
|
||||
saltedPassword := passwd + constantSalt
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(saltedPassword))
|
||||
hashedPassword := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
return hashedPassword
|
||||
h := sha256.Sum256([]byte(passwd + constantSalt))
|
||||
return base64.StdEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
user = models.User{
|
||||
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
|
||||
}
|
||||
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (models.User, error) {
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
Passwd: hashPasswd(passwd),
|
||||
}
|
||||
err = db.Create(&user).Error
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
return user, db.Create(&user).Error
|
||||
}
|
||||
|
||||
func DeleteAccountByUsername(username string) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
|
||||
}
|
||||
|
||||
// 创建默认管理员账户,使用环境变量 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 DeleteAccountByUsernameWithDB(db *gorm.DB, username string) error {
|
||||
return db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
}
|
||||
|
||||
func GetUserByUUID(uuid string) (user models.User, err error) {
|
||||
func GetUserByUUID(uuid string) (models.User, error) {
|
||||
var user models.User
|
||||
return user, dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&user).Error
|
||||
}
|
||||
|
||||
// GetUserBySSO 通过 SSO 信息获取用户
|
||||
func GetUserBySSO(ssoID string) (models.User, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&user).Error
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
var user models.User
|
||||
if err := db.Where("sso_id = ?", ssoID).First(&user).Error; err != nil {
|
||||
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 通过 SSO 信息获取用户
|
||||
func GetUserBySSO(ssoID string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// 首先尝试查找已存在的用户
|
||||
err = db.Where("sso_id = ?", ssoID).First(&user).Error
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 如果找不到用户,返回明确的错误信息
|
||||
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
|
||||
}
|
||||
|
||||
func BindingExternalAccount(uuid string, sso_id string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
|
||||
}
|
||||
|
||||
func UnbindExternalAccount(uuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
|
||||
if err != nil {
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
|
||||
}
|
||||
|
||||
func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if user exists
|
||||
var existingUser models.User
|
||||
result := db.Where("uuid = ?", uuid).First(&existingUser)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("user not found: %s", uuid)
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if name != nil {
|
||||
updates["username"] = *name
|
||||
}
|
||||
if password != nil {
|
||||
updates["passwd"] = hashPasswd(*password)
|
||||
}
|
||||
if sso_type != nil {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
if err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if password != nil {
|
||||
DeleteAllSessions()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,51 +2,70 @@ 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 获取所有会话
|
||||
func GetAllSessions() (sessions []models.Session, err error) {
|
||||
func GetAllSessions() ([]models.Session, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&sessions).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessions, nil
|
||||
var sessions []models.Session
|
||||
return sessions, db.Find(&sessions).Error
|
||||
}
|
||||
|
||||
// CreateSession 创建新会话
|
||||
func CreateSession(uuid string, expires int) (string, error) {
|
||||
func CreateSession(uuid string, expires int, userAgent, ip, login_method string) (string, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
session := utils.GenerateRandomString(32)
|
||||
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: time.Now().Add(time.Duration(expires) * time.Second),
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
return session, nil
|
||||
return session, db.Create(&sessionRecord).Error
|
||||
}
|
||||
|
||||
// GetSession 根据会话 ID 获取 UUID
|
||||
func GetSession(session string) (uuid string, err error) {
|
||||
func GetSession(session string) (string, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var sessionRecord models.Session
|
||||
err = db.Where("session = ?", session).First(&sessionRecord).Error
|
||||
if err != nil {
|
||||
if err := db.Where("session = ?", session).First(&sessionRecord).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().After(sessionRecord.Expires) {
|
||||
if time.Now().UTC().After(sessionRecord.Expires) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -56,30 +75,34 @@ func GetSession(session string) (uuid string, err error) {
|
||||
}
|
||||
|
||||
func GetUserBySession(session string) (models.User, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var sessionRecord models.Session
|
||||
err := db.Where("session = ?", session).First(&sessionRecord).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("session = ?", session).First(&sessionRecord).Error; err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
return GetUserByUUID(sessionRecord.UUID)
|
||||
}
|
||||
|
||||
// DeleteSession 删除指定会话
|
||||
func DeleteSession(session string) (err error) {
|
||||
func DeleteSession(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("session = ?", session).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
return db.Where("session = ?", session).Delete(&models.Session{}).Error
|
||||
}
|
||||
|
||||
func DeleteAllSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("1 = 1").Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
return db.Where("1 = 1").Delete(&models.Session{}).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().UTC(),
|
||||
"latest_user_agent": useragent,
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{}).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func Log(ip, uuid, message, msgType string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
logEntry := &models.Log{
|
||||
IP: ip,
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
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().UTC().AddDate(0, 0, -30)
|
||||
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
|
||||
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
|
||||
}
|
||||
}
|
||||
+133
-200
@@ -1,111 +1,24 @@
|
||||
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
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return dbcore.GetDBInstance().Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -113,61 +26,85 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(update) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
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
|
||||
if err != nil {
|
||||
|
||||
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
|
||||
}
|
||||
return nil
|
||||
|
||||
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
}
|
||||
|
||||
// CreateClient 创建新客户端
|
||||
@@ -180,14 +117,16 @@ 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 {
|
||||
if err := db.Create(&client).Error; 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,65 +141,38 @@ 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 {
|
||||
if err := db.Create(&client).Error; 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
|
||||
}
|
||||
|
||||
/*
|
||||
// GetAllClients 获取所有客户端配置
|
||||
|
||||
func getAllClients() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
*/
|
||||
func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
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()
|
||||
func GetClientByUUID(uuid string) (models.Client, error) {
|
||||
var client models.Client
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (string, error) {
|
||||
var client models.Client
|
||||
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.Token, nil
|
||||
}
|
||||
|
||||
func GetAllClientBasicInfo() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
func GetAllClientBasicInfo() ([]models.Client, error) {
|
||||
var clients []models.Client
|
||||
if err := dbcore.GetDBInstance().Find(&clients).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
@@ -273,16 +185,37 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(updates) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
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()
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
}
|
||||
+72
-111
@@ -1,136 +1,97 @@
|
||||
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"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
// 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()
|
||||
func GetClientUUIDByToken(token string) (string, error) {
|
||||
var client models.Client
|
||||
err = db.Where("token = ?", token).First(&client).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("token = ?", token).First(&client).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
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,
|
||||
// ReportVerify 检查上报数据,防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v2.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] 防止数据溢出
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
*/
|
||||
@@ -1,73 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"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.",
|
||||
AllowCros: 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()
|
||||
// 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_cros",
|
||||
"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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", 1).Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+450
-142
@@ -1,179 +1,487 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// migrateClientData 将旧版ClientInfo数据迁移到新版Client表
|
||||
func migrateClientData(db *gorm.DB) {
|
||||
log.Println("正在迁移旧版ClientInfo数据到新版Client表...")
|
||||
|
||||
// 读取所有ClientInfo记录
|
||||
var clientInfos []common.ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
log.Printf("读取ClientInfo表失败: %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{}{}
|
||||
}
|
||||
|
||||
// 遍历每条记录并更新到Client表
|
||||
for _, info := range clientInfos {
|
||||
// 查找对应的Client记录
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("找不到UUID为%s的Client记录: %v", info.UUID, err)
|
||||
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
|
||||
}
|
||||
// 排除 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)
|
||||
|
||||
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
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 更新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
|
||||
|
||||
// 保存更新后的Client记录
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
log.Printf("更新Client记录失败: %v", err)
|
||||
continue
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 数据迁移完成后,备份并删除旧表
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
log.Printf("备份ClientInfo表失败: %v", err)
|
||||
return
|
||||
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
|
||||
func unzipToDir(zipPath, dstDir string) error {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
log.Println("数据迁移完成,旧表已备份为client_infos_backup")
|
||||
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"
|
||||
|
||||
const (
|
||||
mainSQLiteBusyTimeout = 5 * time.Second
|
||||
mainSQLiteCacheSizeKB = 8 * 1024
|
||||
mainSQLiteWALAutoCheckpoint = 256
|
||||
mainSQLiteJournalSizeLimit = 1 << 20
|
||||
)
|
||||
|
||||
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
|
||||
var versionID string
|
||||
|
||||
// nil means a fresh install; an empty value means an unmarked legacy install.
|
||||
var lastRunVersion *string
|
||||
|
||||
func LastRunVersion() *string {
|
||||
return lastRunVersion
|
||||
}
|
||||
|
||||
// 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() {
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
lastRunVersion = nil
|
||||
if versionRecorded || dbFileExistedAtStartup {
|
||||
lastRunVersion = &prevVersion
|
||||
}
|
||||
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
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 GetDBInstance() *gorm.DB {
|
||||
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)
|
||||
}
|
||||
// 检查是否存在旧版ClientInfo表
|
||||
hasOldClientInfoTable := instance.Migrator().HasTable(&common.ClientInfo{})
|
||||
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
// 如果存在旧表,执行数据迁移
|
||||
if hasOldClientInfoTable {
|
||||
migrateClientData(instance)
|
||||
}
|
||||
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 包中。
|
||||
// models.TrafficReportNotification 同样仅为旧数据库兼容保留,不再建表。
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.OfflineNotification{},
|
||||
&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,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Log struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
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;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,11 @@
|
||||
package messageevent
|
||||
|
||||
const (
|
||||
Offline = "Offline"
|
||||
Online = "Online"
|
||||
Expire = "Expire"
|
||||
Renew = "Renew"
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
+90
-46
@@ -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
|
||||
@@ -39,21 +51,29 @@ type User struct {
|
||||
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
|
||||
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 `gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Session string `gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
|
||||
Expires time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
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)"`
|
||||
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
|
||||
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
|
||||
Expires time.Time `json:"expires" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 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)"`
|
||||
@@ -69,26 +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"`
|
||||
}
|
||||
|
||||
// Config stores site-wide settings
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCros bool `json:"allow_cros" gorm:"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);not null"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255);not null"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// 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,23 @@
|
||||
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"` // 上次通知时间
|
||||
}
|
||||
|
||||
// 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:"{}"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
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"`
|
||||
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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 any `json:"name"` // 主题名称,支持字符串或多语言对象
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description any `json:"description"` // 主题描述,支持字符串或多语言对象
|
||||
Version string `json:"version"` // 版本号
|
||||
Author any `json:"author"` // 作者,支持字符串或多语言对象
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
// IsLocalizedText reports whether a manifest text field has a usable value.
|
||||
func IsLocalizedText(value any) bool {
|
||||
switch text := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(text) != ""
|
||||
case map[string]any:
|
||||
for _, item := range text {
|
||||
if itemText, ok := item.(string); ok && strings.TrimSpace(itemText) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]string:
|
||||
for _, item := range text {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 textbox richtext nodes pingtasks
|
||||
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,24 @@
|
||||
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 {
|
||||
return dbcore.GetDBInstance().Save(config).Error
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package records
|
||||
|
||||
import "github.com/komari-monitor/komari/database/models"
|
||||
|
||||
// LoadProjection is the field subset selected by a load_type request.
|
||||
type LoadProjection struct {
|
||||
Cpu *float32 `json:"cpu,omitempty"`
|
||||
Gpu *float32 `json:"gpu,omitempty"`
|
||||
Ram *int64 `json:"ram,omitempty"`
|
||||
RamTotal *int64 `json:"ram_total,omitempty"`
|
||||
Swap *int64 `json:"swap,omitempty"`
|
||||
SwapTotal *int64 `json:"swap_total,omitempty"`
|
||||
Load *float32 `json:"load,omitempty"`
|
||||
Temp *float32 `json:"temp,omitempty"`
|
||||
Disk *int64 `json:"disk,omitempty"`
|
||||
DiskTotal *int64 `json:"disk_total,omitempty"`
|
||||
NetIn *int64 `json:"net_in,omitempty"`
|
||||
NetOut *int64 `json:"net_out,omitempty"`
|
||||
NetTotalUp *int64 `json:"net_total_up,omitempty"`
|
||||
NetTotalDown *int64 `json:"net_total_down,omitempty"`
|
||||
Process *int `json:"process,omitempty"`
|
||||
Connections *int `json:"connections,omitempty"`
|
||||
ConnectionsUdp *int `json:"connections_udp,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectLoad returns the load_type projection for one record.
|
||||
func ProjectLoad(r models.Record, loadType string) LoadProjection {
|
||||
var p LoadProjection
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
p.Cpu = ptr(r.Cpu)
|
||||
case "gpu":
|
||||
p.Gpu = ptr(r.Gpu)
|
||||
case "ram":
|
||||
p.Ram = ptr(r.Ram)
|
||||
p.RamTotal = ptr(r.RamTotal)
|
||||
case "swap":
|
||||
p.Swap = ptr(r.Swap)
|
||||
p.SwapTotal = ptr(r.SwapTotal)
|
||||
case "load":
|
||||
p.Load = ptr(r.Load)
|
||||
case "temp":
|
||||
p.Temp = ptr(r.Temp)
|
||||
case "disk":
|
||||
p.Disk = ptr(r.Disk)
|
||||
p.DiskTotal = ptr(r.DiskTotal)
|
||||
case "network":
|
||||
p.NetIn = ptr(r.NetIn)
|
||||
p.NetOut = ptr(r.NetOut)
|
||||
p.NetTotalUp = ptr(r.NetTotalUp)
|
||||
p.NetTotalDown = ptr(r.NetTotalDown)
|
||||
case "process":
|
||||
p.Process = ptr(r.Process)
|
||||
case "connections":
|
||||
p.Connections = ptr(r.Connections)
|
||||
p.ConnectionsUdp = ptr(r.ConnectionsUdp)
|
||||
default:
|
||||
p.Cpu = ptr(r.Cpu)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
+73
-214
@@ -1,236 +1,95 @@
|
||||
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/database/models"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
)
|
||||
|
||||
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
|
||||
// QueryGPURecords returns GPU records using the caller's cancellation context.
|
||||
func QueryGPURecords(ctx context.Context, uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return metricstore.GetGPURecordsByClientAndTime(ctx, uuid, start, end)
|
||||
}
|
||||
|
||||
func DeleteRecordBefore(before time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("time < ?", before).Delete(&models.Record{}).Error
|
||||
// GetGPURecordsByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryGPURecords.
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return QueryGPURecords(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
|
||||
}
|
||||
// RecordQueryOptions narrows metric selection and the response point budget.
|
||||
type RecordQueryOptions = metricstore.RecordQueryOptions
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 压缩数据库,针对每个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 {
|
||||
// LoadTypeMetricNames maps the RPC load_type to the stored metrics that back
|
||||
// that field. An empty result means "all load metrics" (including temp and
|
||||
// unknown values, which stay on the compatible full-query path).
|
||||
func LoadTypeMetricNames(loadType string) []string {
|
||||
var names []string
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
return []string{metricstore.MetricCPU}
|
||||
case "gpu":
|
||||
names = []string{metricstore.MetricGPU}
|
||||
case "ram":
|
||||
names = []string{metricstore.MetricRAM}
|
||||
case "swap":
|
||||
names = []string{metricstore.MetricSwap}
|
||||
case "load":
|
||||
names = []string{metricstore.MetricLoad}
|
||||
case "disk":
|
||||
names = []string{metricstore.MetricDisk}
|
||||
case "network":
|
||||
names = []string{metricstore.MetricNetIn, metricstore.MetricNetOut, metricstore.MetricNetTotalUp, metricstore.MetricNetTotalDown}
|
||||
case "process":
|
||||
names = []string{metricstore.MetricProcess}
|
||||
case "connections":
|
||||
names = []string{metricstore.MetricConnections, metricstore.MetricConnectionsUDP}
|
||||
default:
|
||||
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
|
||||
// CPU is the common presence anchor: the previous implementation built one
|
||||
// record per timestamp where any load metric existed. Including CPU keeps
|
||||
// those timestamps (and zero values for a missing selected metric) without
|
||||
// going back to all fifteen metrics.
|
||||
return append(names, metricstore.MetricCPU)
|
||||
}
|
||||
|
||||
func Save(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
// QueryRecords returns one client's records, or all clients when uuid is empty.
|
||||
// Request handlers should use this context-aware entry point.
|
||||
func QueryRecords(ctx context.Context, uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
if uuid == "" {
|
||||
return metricstore.GetRecordsByTime(ctx, start, end, opts...)
|
||||
}
|
||||
return metricstore.GetRecordsByClientAndTime(ctx, uuid, start, end, opts...)
|
||||
}
|
||||
|
||||
// GetRecordsByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryRecords.
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end, opts...)
|
||||
}
|
||||
|
||||
// GetRecordMetricMaxByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use metricstore.GetRecordMetricMaxByClientAndTime.
|
||||
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
|
||||
}
|
||||
|
||||
// GetRecordsByTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryRecords with an empty uuid.
|
||||
func GetRecordsByTime(start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end, opts...)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"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.
|
||||
return tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id)).Error
|
||||
})
|
||||
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 {
|
||||
if err := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", order[id]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for _, task := range tasks {
|
||||
if slices.Contains(task.Clients, uuid) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/sampling"
|
||||
)
|
||||
|
||||
// PingClientSummary is the per-client aggregate shared by ping RPC adapters.
|
||||
type PingClientSummary struct {
|
||||
Total int
|
||||
Loss int
|
||||
Min int
|
||||
Max int
|
||||
LossPercent float64
|
||||
}
|
||||
|
||||
// PingTaskSummary is the per-task aggregate shared by ping RPC adapters.
|
||||
type PingTaskSummary struct {
|
||||
Total int
|
||||
Loss int
|
||||
Valid int
|
||||
Min int
|
||||
Max int
|
||||
Avg int
|
||||
Latest int
|
||||
P50 int
|
||||
P99 int
|
||||
LossPercent float64
|
||||
P99P50Ratio float64
|
||||
}
|
||||
|
||||
// PingSummary aggregates records once so legacy and public ping handlers only
|
||||
// differ in response shape.
|
||||
type PingSummary struct {
|
||||
Clients map[string]PingClientSummary
|
||||
Tasks map[uint]PingTaskSummary
|
||||
}
|
||||
|
||||
type pingClientAccumulator struct {
|
||||
total, loss, min, max int
|
||||
}
|
||||
|
||||
type pingTaskAccumulator struct {
|
||||
total, loss, min, max, sum, valid int
|
||||
latest int
|
||||
latestTime time.Time
|
||||
latencies []int
|
||||
}
|
||||
|
||||
// SummarizePingRecords computes client and task aggregates in one pass.
|
||||
func SummarizePingRecords(recs []models.PingRecord) PingSummary {
|
||||
clients := make(map[string]*pingClientAccumulator)
|
||||
taskMap := make(map[uint]*pingTaskAccumulator)
|
||||
for _, r := range recs {
|
||||
cs := clients[r.Client]
|
||||
if cs == nil {
|
||||
cs = &pingClientAccumulator{}
|
||||
clients[r.Client] = cs
|
||||
}
|
||||
cs.total++
|
||||
ts := taskMap[r.TaskId]
|
||||
if ts == nil {
|
||||
ts = &pingTaskAccumulator{latest: -1}
|
||||
taskMap[r.TaskId] = ts
|
||||
}
|
||||
ts.total++
|
||||
if r.Value < 0 {
|
||||
cs.loss++
|
||||
ts.loss++
|
||||
continue
|
||||
}
|
||||
if cs.min == 0 || r.Value < cs.min {
|
||||
cs.min = r.Value
|
||||
}
|
||||
if r.Value > cs.max {
|
||||
cs.max = r.Value
|
||||
}
|
||||
ts.valid++
|
||||
ts.sum += r.Value
|
||||
if ts.min == 0 || r.Value < ts.min {
|
||||
ts.min = r.Value
|
||||
}
|
||||
if r.Value > ts.max {
|
||||
ts.max = r.Value
|
||||
}
|
||||
if ts.latestTime.IsZero() || r.Time.After(ts.latestTime) {
|
||||
ts.latestTime = r.Time
|
||||
ts.latest = r.Value
|
||||
}
|
||||
ts.latencies = append(ts.latencies, r.Value)
|
||||
}
|
||||
|
||||
out := PingSummary{Clients: make(map[string]PingClientSummary, len(clients)), Tasks: make(map[uint]PingTaskSummary, len(taskMap))}
|
||||
for client, v := range clients {
|
||||
loss := 0.0
|
||||
if v.total > 0 {
|
||||
loss = float64(v.loss) / float64(v.total) * 100
|
||||
}
|
||||
out.Clients[client] = PingClientSummary{Total: v.total, Loss: v.loss, Min: v.min, Max: v.max, LossPercent: loss}
|
||||
}
|
||||
for id, v := range taskMap {
|
||||
loss := 0.0
|
||||
if v.total > 0 {
|
||||
loss = float64(v.loss) / float64(v.total) * 100
|
||||
}
|
||||
avg := 0
|
||||
if v.valid > 0 {
|
||||
avg = v.sum / v.valid
|
||||
}
|
||||
p50, p99 := pingPercentiles(v.latencies)
|
||||
ratio := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
|
||||
ratio = float64(p99-p50) / adjustedBase
|
||||
}
|
||||
out.Tasks[id] = PingTaskSummary{
|
||||
Total: v.total, Loss: v.loss, Valid: v.valid, Min: v.min, Max: v.max,
|
||||
Avg: avg, Latest: v.latest, P50: p50, P99: p99, LossPercent: loss, P99P50Ratio: ratio,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SamplePingRecordsByTask applies the legacy ping maxCount policy: allocate
|
||||
// points proportionally by task, sample each task evenly and return ascending
|
||||
// by time. Statistics must be computed from the unsampled records.
|
||||
func SamplePingRecordsByTask(recs []models.PingRecord, maxCount int) []models.PingRecord {
|
||||
if maxCount <= 0 || len(recs) <= maxCount {
|
||||
return recs
|
||||
}
|
||||
groups := make(map[uint][]models.PingRecord)
|
||||
for _, r := range recs {
|
||||
groups[r.TaskId] = append(groups[r.TaskId], r)
|
||||
}
|
||||
meta := make([]sampling.Group[uint], 0, len(groups))
|
||||
for id, points := range groups {
|
||||
sort.Slice(points, func(i, j int) bool { return points[i].Time.Before(points[j].Time) })
|
||||
groups[id] = points
|
||||
meta = append(meta, sampling.Group[uint]{Key: id, Length: len(points)})
|
||||
}
|
||||
targets := sampling.AllocateTargets(meta, maxCount)
|
||||
out := make([]models.PingRecord, 0, maxCount)
|
||||
for id, points := range groups {
|
||||
out = append(out, sampling.SampleEvenly(points, targets[id])...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
|
||||
return out
|
||||
}
|
||||
|
||||
func pingPercentiles(values []int) (int, int) {
|
||||
if len(values) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
sort.Ints(values)
|
||||
return pingPercentile(values, 0.50), pingPercentile(values, 0.99)
|
||||
}
|
||||
|
||||
func pingPercentile(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if percentile <= 0 {
|
||||
return values[0]
|
||||
}
|
||||
if percentile >= 1 {
|
||||
return values[len(values)-1]
|
||||
}
|
||||
pos := float64(len(values)-1) * percentile
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return values[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
return int(math.Round(float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func CreateTask(taskId string, clients []string, command string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
task := models.Task{
|
||||
TaskId: taskId,
|
||||
Clients: models.StringArray(clients),
|
||||
Command: command,
|
||||
}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var taskResults []models.TaskResult
|
||||
for _, client := range clients {
|
||||
taskResults = append(taskResults, models.TaskResult{
|
||||
TaskId: taskId,
|
||||
Client: client,
|
||||
Result: "",
|
||||
ExitCode: nil,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
if len(taskResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&taskResults).Error
|
||||
}
|
||||
func GetTaskByTaskId(taskId string) (*models.Task, error) {
|
||||
var task models.Task
|
||||
if err := dbcore.GetDBInstance().Where("task_id = ?", taskId).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
func GetTasksByClientId(clientId string) ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
if err := dbcore.GetDBInstance().Where("clients LIKE ?", "%"+clientId+"%").Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error) {
|
||||
var result models.TaskResult
|
||||
if err := dbcore.GetDBInstance().Where("task_id = ? AND client = ?", taskId, clientId).First(&result).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func GetAllTasks() ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
|
||||
var results []models.TaskResult
|
||||
if err := dbcore.GetDBInstance().Where("task_id = ?", taskId).Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
Where("task_id = ? AND client = ?", taskId, clientId).
|
||||
Updates(map[string]interface{}{
|
||||
"result": result,
|
||||
"exit_code": exitCode,
|
||||
"finished_at": timestamp.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func ClearTaskResultsByTimeBefore(before time.Time) 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,120 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/internal/managedconfig"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/web/public"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
items := themeConfigurationItems(cst.Theme)
|
||||
if cst.Theme != "default" {
|
||||
for _, item := range items {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := tc_data[item.Key]; !exists {
|
||||
tc_data[item.Key] = managedconfig.DefaultValue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := managedconfig.ResolveForOutput(tc_data, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func themeConfigurationItems(short string) []models.ManagedThemeConfigurationItem {
|
||||
var manifest models.Theme
|
||||
if short == "default" {
|
||||
data, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err != nil || json.Unmarshal(data, &manifest) != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(filepath.Join("./data/theme", short, "komari-theme.json"))
|
||||
if err != nil || json.Unmarshal(data, &manifest) != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return managedconfig.Items(manifest.Configuration)
|
||||
}
|
||||
@@ -1,44 +1,53 @@
|
||||
module github.com/komari-monitor/komari
|
||||
|
||||
go 1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/Calcium-Ion/moejs v0.1.0-alpha.9
|
||||
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.12.1
|
||||
golang.org/x/sys v0.33.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
|
||||
)
|
||||
|
||||
require go.yaml.in/yaml/v3 v3.0.5 // 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
|
||||
|
||||
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/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/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/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
|
||||
@@ -46,17 +55,16 @@ require (
|
||||
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
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
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/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,5 +1,9 @@
|
||||
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/Calcium-Ion/moejs v0.1.0-alpha.9 h1:WY1ebChegtafhPjHSQx3Q+gfnrjqYwHewHuO9k+v7e0=
|
||||
github.com/Calcium-Ion/moejs v0.1.0-alpha.9/go.mod h1:9YqHj36EV7pzV/vlEYSw49f3oggeJFdmqcjM9TmDpHs=
|
||||
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=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -15,8 +19,6 @@ 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/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/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
|
||||
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
@@ -29,13 +31,12 @@ 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-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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -43,18 +44,31 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
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.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=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -70,10 +84,14 @@ 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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.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=
|
||||
@@ -91,36 +109,40 @@ 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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
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.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
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=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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.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=
|
||||
+1465
File diff suppressed because it is too large.
Load diff
@@ -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,78 @@
|
||||
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
|
||||
SSRFProtectionEnabled bool `json:"ssrf_protection_enabled" default:"false"` // 是否启用 SSRF 防护,默认关闭
|
||||
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"
|
||||
SSRFProtectionEnabledKey = "ssrf_protection_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"
|
||||
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,144 @@
|
||||
// Package managedconfig implements the shared managed-configuration behavior
|
||||
// used by themes and plugins.
|
||||
package managedconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeNodes = "nodes"
|
||||
TypePingTasks = "pingtasks"
|
||||
)
|
||||
|
||||
// Items returns the declared managed configuration items. An omitted type is
|
||||
// managed, matching the theme configuration default.
|
||||
func Items(configuration models.Configuration) []models.ManagedThemeConfigurationItem {
|
||||
if configuration.Type != "" && !strings.EqualFold(configuration.Type, models.ThemeConfigurationManaged) {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(configuration.Data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var items []models.ManagedThemeConfigurationItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func DefaultValue(item models.ManagedThemeConfigurationItem) any {
|
||||
value := item.Default
|
||||
if item.Type == "select" && (value == nil || value == "") && item.Options != "" {
|
||||
options := strings.Split(item.Options, ",")
|
||||
if len(options) > 0 {
|
||||
return strings.TrimSpace(options[0])
|
||||
}
|
||||
}
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
switch item.Type {
|
||||
case "number":
|
||||
return float64(0)
|
||||
case "switch":
|
||||
return false
|
||||
case TypeNodes, TypePingTasks:
|
||||
return "[]"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveForOutput decodes declared selectors, drops deleted references, and
|
||||
// returns typed arrays suitable for public theme settings and plugin config.
|
||||
func ResolveForOutput(values map[string]any, items []models.ManagedThemeConfigurationItem) error {
|
||||
hasNodes := false
|
||||
hasPingTasks := false
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case TypeNodes:
|
||||
hasNodes = true
|
||||
case TypePingTasks:
|
||||
hasPingTasks = true
|
||||
}
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
liveNodes := map[string]struct{}{}
|
||||
if hasNodes {
|
||||
var nodes []models.Client
|
||||
if err := db.Select("uuid").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
liveNodes[node.UUID] = struct{}{}
|
||||
}
|
||||
}
|
||||
livePingTasks := map[uint]struct{}{}
|
||||
if hasPingTasks {
|
||||
var tasks []models.PingTask
|
||||
if err := db.Select("id").Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
livePingTasks[task.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
switch item.Type {
|
||||
case TypeNodes:
|
||||
selected := NodeIDs(values[item.Key])
|
||||
filtered := make([]string, 0, len(selected))
|
||||
for _, id := range selected {
|
||||
if _, ok := liveNodes[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
values[item.Key] = filtered
|
||||
case TypePingTasks:
|
||||
selected := PingTaskIDs(values[item.Key])
|
||||
filtered := make([]uint, 0, len(selected))
|
||||
for _, id := range selected {
|
||||
if _, ok := livePingTasks[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
values[item.Key] = filtered
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NodeIDs(value any) []string {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if json.Unmarshal([]byte(raw), &ids) != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func PingTaskIDs(value any) []uint {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ids []uint
|
||||
if json.Unmarshal([]byte(raw), &ids) != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -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,316 @@
|
||||
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) {
|
||||
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) {
|
||||
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,307 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// RecordQueryOptions narrows the record reconstruction work. MetricNames
|
||||
// limits which stored load metrics are queried; empty means all load metrics.
|
||||
// MaxPoints is a total response-point budget; it is applied before aggregation,
|
||||
// so an all-client query does not materialize thousands of points per client
|
||||
// only to sample them afterwards.
|
||||
type RecordQueryOptions struct {
|
||||
MetricNames []string
|
||||
MaxPoints int
|
||||
}
|
||||
|
||||
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record。
|
||||
// opts 为空表示兼容旧行为;有值时用于指标选择和点数预算。
|
||||
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordsFromSeries(ctx, s, clientUUID, start, end, false, firstRecordOptions(opts))
|
||||
}
|
||||
|
||||
func firstRecordOptions(opts []RecordQueryOptions) RecordQueryOptions {
|
||||
if len(opts) == 0 {
|
||||
return RecordQueryOptions{}
|
||||
}
|
||||
return opts[0]
|
||||
}
|
||||
|
||||
// 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 查询所有客户端在时间范围内的记录。
|
||||
// opts 为空表示兼容旧行为;有值时用于指标选择和总点数预算。
|
||||
func GetRecordsByTime(ctx context.Context, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
// Query all entities together. Entity-only grouping retains the old per-client
|
||||
// aggregation across tags without repeating the tier scan for every client.
|
||||
return getRecordsFromSeries(ctx, s, "", start, end, true, firstRecordOptions(opts))
|
||||
}
|
||||
|
||||
type recordSeriesKey struct {
|
||||
client string
|
||||
ts int64
|
||||
}
|
||||
|
||||
func getRecordsFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time, allEntities bool, opts RecordQueryOptions) ([]models.Record, error) {
|
||||
metricNames := opts.MetricNames
|
||||
if len(metricNames) == 0 {
|
||||
metricNames = loadRecordMetricNames
|
||||
}
|
||||
specs := make([]MetricSpec, 0, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
specs = append(specs, MetricSpec{Name: name, Aggregations: []metric.Aggregation{recordMetricAggregation(name)}})
|
||||
}
|
||||
entities := EntityScope{All: allEntities}
|
||||
grouping := GroupMerged
|
||||
if allEntities {
|
||||
grouping = GroupEntity
|
||||
} else {
|
||||
entities.IDs = []string{clientUUID}
|
||||
}
|
||||
rounding := RoundFloor
|
||||
if opts.MaxPoints > 0 {
|
||||
rounding = RoundCeil
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: entities, Specs: specs, Start: start, End: end,
|
||||
Order: metric.OrderAsc, Grouping: grouping,
|
||||
Budget: PointBudget{PerSeries: 500, Total: opts.MaxPoints},
|
||||
Rounding: rounding, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metrics: %w", err)
|
||||
}
|
||||
return assembleRecordsFromSeries(loaded.Values, clientUUID), nil
|
||||
}
|
||||
|
||||
// assembleRecordsFromSeries unions metric buckets without allocating a record per key.
|
||||
func assembleRecordsFromSeries(values map[string]map[metric.Aggregation][]metric.AggregatePoint, clientUUID string) []models.Record {
|
||||
// Aligned metrics normally share buckets: the largest selected metric is a
|
||||
// conservative capacity estimate, not the sum of the metric fanout. Missing
|
||||
// buckets can grow the union beyond it (and duplicate seconds can shrink it).
|
||||
capacity := 0
|
||||
for name, aggregations := range values {
|
||||
if n := len(aggregations[recordMetricAggregation(name)]); n > capacity {
|
||||
capacity = n
|
||||
}
|
||||
}
|
||||
records := make([]models.Record, 0, capacity)
|
||||
recordIndex := make(map[recordSeriesKey]int, capacity)
|
||||
for metricName, aggMap := range values {
|
||||
agg := recordMetricAggregation(metricName)
|
||||
points, ok := aggMap[agg]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
key := recordSeriesKey{client: entityID, ts: point.Bucket.Unix()}
|
||||
index, exists := recordIndex[key]
|
||||
if !exists {
|
||||
index = len(records)
|
||||
recordIndex[key] = index
|
||||
records = append(records, models.Record{
|
||||
Client: entityID,
|
||||
Time: point.Bucket.UTC(),
|
||||
})
|
||||
}
|
||||
// Resolve the address after append: the backing array may have moved.
|
||||
applyRecordMetricValue(&records[index], metricName, point.Value)
|
||||
}
|
||||
}
|
||||
|
||||
sortRecords(records)
|
||||
return records
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: EntityScope{IDs: []string{clientUUID}},
|
||||
Specs: []MetricSpec{{Name: metricName, Aggregations: []metric.Aggregation{metric.AggMax}}},
|
||||
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupMerged,
|
||||
Budget: PointBudget{PerSeries: 500}, Rounding: RoundFloor, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
|
||||
}
|
||||
|
||||
points := loaded.Values[metricName][metric.AggMax]
|
||||
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 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)
|
||||
|
||||
specs := make([]MetricSpec, 0, len(gpuMetrics))
|
||||
for _, name := range gpuMetrics {
|
||||
specs = append(specs, MetricSpec{Name: name})
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QueryPoints, Source: SourceRaw,
|
||||
OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: EntityScope{IDs: []string{clientUUID}}, Specs: specs,
|
||||
Start: start, End: end, Order: metric.OrderAsc, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query GPU metrics: %w", err)
|
||||
}
|
||||
for _, metricName := range gpuMetrics {
|
||||
points := loaded.Raw[metricName]
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
func TestInspectAndReclaimStorage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
info, err := InspectStorage(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect storage: %v", err)
|
||||
}
|
||||
if info.Driver != metric.DriverSQLite || info.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("unexpected storage info: %#v", info)
|
||||
}
|
||||
if info.Size != 0 {
|
||||
t.Fatalf("in-memory storage size = %d, want 0", info.Size)
|
||||
}
|
||||
|
||||
result, err := ReclaimSpace(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reclaim space: %v", err)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("unexpected maintenance result: %#v", result)
|
||||
}
|
||||
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
|
||||
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimSpaceWaitsForStore(t *testing.T) {
|
||||
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
if err := storeOperations.AcquireShared(context.Background()); err != nil {
|
||||
t.Fatalf("acquire shared store operation gate: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var result MaintenanceResult
|
||||
var reclaimErr error
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
go func() {
|
||||
result, reclaimErr = ReclaimSpace(canceledCtx)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("reclaim returned while another operation held the gate")
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
storeOperations.ReleaseShared()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reclaim did not resume after the gate was released")
|
||||
}
|
||||
if reclaimErr != nil {
|
||||
t.Fatalf("reclaim error: %v", reclaimErr)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("reclaim result lost store metadata: %#v", result)
|
||||
}
|
||||
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
|
||||
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
|
||||
}
|
||||
compactCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if _, err := Compact(compactCtx, time.Now()); err != nil {
|
||||
t.Fatalf("compact should not wait for report writes: %v", err)
|
||||
}
|
||||
if !compactOperations.TryAcquire() {
|
||||
t.Fatal("acquire compact operation gate")
|
||||
}
|
||||
defer compactOperations.Release()
|
||||
if _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
|
||||
t.Fatalf("overlapping compact error = %v, want %v", err, ErrCompactInProgress)
|
||||
}
|
||||
if _, err := CleanupExpired(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
|
||||
t.Fatalf("overlapping retention cleanup error = %v, want %v", err, ErrCompactInProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStorageRequiresInitializedStore(t *testing.T) {
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = nil
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
})
|
||||
|
||||
if _, err := InspectStorage(context.Background()); !errors.Is(err, ErrStoreNotInitialized) {
|
||||
t.Fatalf("inspect error = %v, want %v", err, ErrStoreNotInitialized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseStoreContextCancelsMigrationBeforeTakingStoreLock(t *testing.T) {
|
||||
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, s)
|
||||
|
||||
migrationCtx, cancelMigration := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
if !storeOperations.TryAcquire() {
|
||||
t.Fatal("acquire store operation gate")
|
||||
}
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = cancelMigration
|
||||
storeMigDone = done
|
||||
storeMigMu.Unlock()
|
||||
|
||||
go func() {
|
||||
<-migrationCtx.Done()
|
||||
storeOperations.Release()
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = nil
|
||||
storeMigDone = nil
|
||||
close(done)
|
||||
storeMigMu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := CloseStoreContext(ctx); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if migrationCtx.Err() == nil {
|
||||
t.Fatal("store migration was not canceled before close")
|
||||
}
|
||||
if err := Reload(context.Background()); !errors.Is(err, ErrStoreBusy) {
|
||||
t.Fatalf("reload after close error = %v, want %v", err, ErrStoreBusy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOperationWaitsRespectContext(t *testing.T) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
t.Fatal("acquire store operation gate")
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := InspectStorage(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("inspect error = %v, want context canceled", err)
|
||||
}
|
||||
if err := Reload(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("reload error = %v, want context canceled", err)
|
||||
}
|
||||
if err := CloseStoreContext(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("close error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installTestStore(t *testing.T, s *metric.Store) {
|
||||
t.Helper()
|
||||
storeMigMu.Lock()
|
||||
previousClosing := storeClosing
|
||||
storeClosing = false
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
previousFingerprint := storeFingerprint
|
||||
store = s
|
||||
storeFingerprint = "test|memory"
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = previousClosing
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeFingerprint = previousFingerprint
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package metricstore
|
||||
|
||||
const (
|
||||
MetricCPU = "cpu.usage"
|
||||
MetricGPU = "gpu.usage"
|
||||
MetricGPUDeviceUsage = "gpu.device.usage"
|
||||
MetricGPUMem = "gpu.memory.used"
|
||||
MetricGPUMemTotal = "gpu.memory.total"
|
||||
MetricGPUTemp = "gpu.temperature"
|
||||
MetricRAM = "memory.used"
|
||||
MetricSwap = "swap.used"
|
||||
MetricLoad = "load.average"
|
||||
MetricDisk = "disk.used"
|
||||
MetricNetIn = "net.in.rate"
|
||||
MetricNetOut = "net.out.rate"
|
||||
MetricNetTotalUp = "net.total.up"
|
||||
MetricNetTotalDown = "net.total.down"
|
||||
MetricTrafficUp = "traffic.up"
|
||||
MetricTrafficDown = "traffic.down"
|
||||
MetricProcess = "process.count"
|
||||
MetricConnections = "connections.tcp"
|
||||
MetricConnectionsUDP = "connections.udp"
|
||||
MetricPingLatency = "ping.latency_ms"
|
||||
MetricPingLoss = "ping.loss"
|
||||
)
|
||||
|
||||
// loadRecordMetricNames are the entity-level metrics used to reconstruct the
|
||||
// legacy Record response shape.
|
||||
var loadRecordMetricNames = []string{
|
||||
MetricCPU, MetricGPU, MetricRAM, MetricSwap, MetricLoad, MetricDisk, MetricNetIn, MetricNetOut,
|
||||
MetricNetTotalUp, MetricNetTotalDown, MetricTrafficUp, MetricTrafficDown,
|
||||
MetricProcess, MetricConnections, MetricConnectionsUDP,
|
||||
}
|
||||
|
||||
// gpuDeviceRecordMetricNames are stored separately from the entity-level GPU
|
||||
// average and are included when deleting all system records.
|
||||
var gpuDeviceRecordMetricNames = []string{
|
||||
MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp,
|
||||
}
|
||||
|
||||
var recordMetricNames = joinMetricNames(loadRecordMetricNames, gpuDeviceRecordMetricNames)
|
||||
|
||||
// Ping has an independent retention and cleanup boundary.
|
||||
var pingMetricNames = []string{MetricPingLatency, MetricPingLoss}
|
||||
|
||||
var builtinMetricNames = joinMetricNames(recordMetricNames, pingMetricNames)
|
||||
|
||||
func metricNameForRecordField(name string) (string, bool) {
|
||||
switch name {
|
||||
case "cpu":
|
||||
return MetricCPU, true
|
||||
case "gpu":
|
||||
return MetricGPU, true
|
||||
case "ram":
|
||||
return MetricRAM, true
|
||||
case "swap":
|
||||
return MetricSwap, true
|
||||
case "load":
|
||||
return MetricLoad, true
|
||||
case "disk":
|
||||
return MetricDisk, true
|
||||
case "net_in", "netin":
|
||||
return MetricNetIn, true
|
||||
case "net_out", "netout":
|
||||
return MetricNetOut, true
|
||||
case "net_total_up":
|
||||
return MetricNetTotalUp, true
|
||||
case "net_total_down":
|
||||
return MetricNetTotalDown, true
|
||||
case "traffic_up":
|
||||
return MetricTrafficUp, true
|
||||
case "traffic_down":
|
||||
return MetricTrafficDown, true
|
||||
case "process":
|
||||
return MetricProcess, true
|
||||
case "connections":
|
||||
return MetricConnections, true
|
||||
case "connections_udp":
|
||||
return MetricConnectionsUDP, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func joinMetricNames(groups ...[]string) []string {
|
||||
total := 0
|
||||
for _, group := range groups {
|
||||
total += len(group)
|
||||
}
|
||||
names := make([]string, 0, total)
|
||||
for _, group := range groups {
|
||||
names = append(names, group...)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
func TestDefaultRollupPolicy(t *testing.T) {
|
||||
policy := defaultRollupPolicy()
|
||||
if err := policy.Validate(); err != nil {
|
||||
t.Fatalf("default rollup policy should validate: %v", err)
|
||||
}
|
||||
if policy.RawRetention != DefaultRollupRawRetention {
|
||||
t.Fatalf("raw retention = %s, want %s", policy.RawRetention, DefaultRollupRawRetention)
|
||||
}
|
||||
if len(policy.Tiers) != 4 {
|
||||
t.Fatalf("expected 4 rollup tiers, got %d", len(policy.Tiers))
|
||||
}
|
||||
|
||||
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour, 24 * time.Hour}
|
||||
wantRetentions := []time.Duration{600 * time.Minute, 600 * 5 * time.Minute, 600 * time.Hour, 100 * 365 * 24 * time.Hour}
|
||||
for i := range wantIntervals {
|
||||
if policy.Tiers[i].Interval != wantIntervals[i] {
|
||||
t.Fatalf("tier %d interval = %s, want %s", i, policy.Tiers[i].Interval, wantIntervals[i])
|
||||
}
|
||||
if policy.Tiers[i].Retention != wantRetentions[i] {
|
||||
t.Fatalf("tier %d retention = %s, want %s", i, policy.Tiers[i].Retention, wantRetentions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
TablePrefix: "metric_",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if !cfg.RollupPolicy.Enabled() {
|
||||
t.Fatal("expected default rollup policy to be enabled")
|
||||
}
|
||||
if cfg.RollupPolicy.RawRetention != DefaultRollupRawRetention {
|
||||
t.Fatalf("raw retention = %s, want %s", cfg.RollupPolicy.RawRetention, DefaultRollupRawRetention)
|
||||
}
|
||||
if cfg.SQLite.ReadPoolSize != 2 {
|
||||
t.Fatalf("metric store read pool = %d, want fixed size 2", cfg.SQLite.ReadPoolSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
wantRollupRetention := 100 * 365 * 24 * time.Hour
|
||||
lastTier := cfg.RollupPolicy.Tiers[len(cfg.RollupPolicy.Tiers)-1]
|
||||
if lastTier.Retention != wantRollupRetention {
|
||||
t.Fatalf("rollup retention = %s, want %s", lastTier.Retention, wantRollupRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigUsesCustomRollupRetention(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 300,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
|
||||
want := []time.Duration{30 * time.Minute, 150 * time.Minute, 300 * time.Hour}
|
||||
if len(cfg.RollupPolicy.Tiers) != 4 {
|
||||
t.Fatalf("tier count = %d, want 4", len(cfg.RollupPolicy.Tiers))
|
||||
}
|
||||
for i, retention := range want {
|
||||
if cfg.RollupPolicy.Tiers[i].Retention != retention {
|
||||
t.Fatalf("tier %d retention = %s, want %s", i, cfg.RollupPolicy.Tiers[i].Retention, retention)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigRejectsInvalidRollupRetention(t *testing.T) {
|
||||
tests := []MetricStoreConfig{
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: -1,
|
||||
},
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 120,
|
||||
RollupFiveMinuteRetentionMinutes: 60,
|
||||
RollupHourRetentionHours: 600,
|
||||
},
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 1,
|
||||
},
|
||||
}
|
||||
for i, cfg := range tests {
|
||||
if _, err := buildMetricConfig(&cfg, false); err == nil {
|
||||
t.Fatalf("case %d: expected invalid rollup retention error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigDefaultsOmittedRollupRetention(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{Driver: "sqlite", DSN: ":memory:"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if got, want := cfg.RollupPolicy.Tiers[0].Retention, 600*time.Minute; got != want {
|
||||
t.Fatalf("minute retention = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := cfg.RollupPolicy.Tiers[1].Retention, 3000*time.Minute; got != want {
|
||||
t.Fatalf("five-minute retention = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromFingerprintPreservesRollupRetention(t *testing.T) {
|
||||
base := &MetricStoreConfig{
|
||||
TablePrefix: "metrics_",
|
||||
MaxOpenConns: 11,
|
||||
MaxIdleConns: 4,
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 300,
|
||||
}
|
||||
|
||||
got, err := configFromFingerprint("mysql|user:password@tcp(host:3306)/metrics", base)
|
||||
if err != nil {
|
||||
t.Fatalf("config from fingerprint: %v", err)
|
||||
}
|
||||
if got.RollupMinuteRetentionMinutes != base.RollupMinuteRetentionMinutes ||
|
||||
got.RollupFiveMinuteRetentionMinutes != base.RollupFiveMinuteRetentionMinutes ||
|
||||
got.RollupHourRetentionHours != base.RollupHourRetentionHours {
|
||||
t.Fatalf("rollup retention was not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigAlwaysEnablesDownsampling(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if !cfg.RollupPolicy.Enabled() {
|
||||
t.Fatal("expected rollup policy to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPingRecordsReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{
|
||||
Name: MetricPingLatency,
|
||||
Type: metric.TypeGauge,
|
||||
RetentionDays: 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("create ping metric: %v", err)
|
||||
}
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-20 * time.Minute), Value: 20, Tags: map[string]string{"task_id": "7"}},
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-10 * time.Minute), Value: 10, Tags: map[string]string{"task_id": "7"}},
|
||||
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-5 * time.Minute), Value: 5, Tags: map[string]string{"task_id": "7"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("write ping points: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact ping points: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
}()
|
||||
|
||||
records, err := GetPingRecords(ctx, "node-a", 7, now.Add(-30*time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get ping records: %v", err)
|
||||
}
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("expected 3 ping records across raw and rollup data, got %d: %#v", len(records), records)
|
||||
}
|
||||
if records[0].Value != 5 || records[1].Value != 10 || records[2].Value != 20 {
|
||||
t.Fatalf("unexpected ping values in descending order: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *testing.T) {
|
||||
if defaultBuiltinMetricRetentionDays != 1 {
|
||||
t.Fatalf("default built-in metric retention = %d, want 1 day", defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create definitions: %v", err)
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list definitions: %v", err)
|
||||
}
|
||||
if len(defs) != 21 {
|
||||
t.Fatalf("definition count = %d, want 21", len(defs))
|
||||
}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays != defaultBuiltinMetricRetentionDays {
|
||||
t.Fatalf("%s retention = %d, want %d", def.Name, def.RetentionDays, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
cpu, err := s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("get cpu definition: %v", err)
|
||||
}
|
||||
cpu.RetentionDays = 60
|
||||
if err := s.UpsertMetric(ctx, cpu); err != nil {
|
||||
t.Fatalf("override cpu retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("recreate definitions: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload cpu definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 60 {
|
||||
t.Fatalf("cpu retention = %d, want preserved override 60", cpu.RetentionDays)
|
||||
}
|
||||
if _, err := s.SetMetricRetention(ctx, MetricCPU, 0); err != nil {
|
||||
t.Fatalf("disable cpu retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("refresh disabled definition: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload disabled cpu definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 0 {
|
||||
t.Fatalf("cpu retention = %d, want preserved disabled state", cpu.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsKeepsExistingMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: "memory.total", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
t.Fatalf("create obsolete definition: %v", err)
|
||||
}
|
||||
if err := s.Write(ctx, metric.Point{MetricName: "memory.total", EntityID: "node-a", Timestamp: time.Now().UTC(), Value: 1024}); err != nil {
|
||||
t.Fatalf("write obsolete point: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("refresh built-in definitions: %v", err)
|
||||
}
|
||||
definition, err := s.GetMetric(ctx, "memory.total")
|
||||
if err != nil {
|
||||
t.Fatalf("existing definition was removed: %v", err)
|
||||
}
|
||||
if definition.RetentionDays != 1 {
|
||||
t.Fatalf("existing retention = %d, want 1", definition.RetentionDays)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{MetricName: "memory.total", EntityID: "node-a", Start: time.Now().UTC().Add(-time.Hour), End: time.Now().UTC().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("query existing points: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != 1024 {
|
||||
t.Fatalf("existing points were removed: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsUsesLegacySpanOnlyForNewDefinitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 10); err != nil {
|
||||
t.Fatalf("create migration definitions: %v", err)
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list migration definitions: %v", err)
|
||||
}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays != 10 {
|
||||
t.Fatalf("%s retention = %d, want legacy span 10", def.Name, def.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
cpu, err := s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("get CPU definition: %v", err)
|
||||
}
|
||||
cpu.RetentionDays = 3
|
||||
if err := s.UpsertMetric(ctx, cpu); err != nil {
|
||||
t.Fatalf("override CPU retention: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 20); err != nil {
|
||||
t.Fatalf("refresh migration definitions: %v", err)
|
||||
}
|
||||
cpu, err = s.GetMetric(ctx, MetricCPU)
|
||||
if err != nil {
|
||||
t.Fatalf("reload CPU definition: %v", err)
|
||||
}
|
||||
if cpu.RetentionDays != 3 {
|
||||
t.Fatalf("existing CPU retention = %d, want preserved 3", cpu.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRetentionSummaryUsesAllMetricDefinitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
}()
|
||||
|
||||
empty, err := GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize empty store: %v", err)
|
||||
}
|
||||
if empty.AllPositive || empty.MaxDays != 0 {
|
||||
t.Fatalf("unexpected empty summary: %#v", empty)
|
||||
}
|
||||
for _, def := range []metric.Definition{
|
||||
{Name: "short", Type: metric.TypeGauge, RetentionDays: 7},
|
||||
{Name: "long", Type: metric.TypeGauge, RetentionDays: 60},
|
||||
} {
|
||||
if err := s.UpsertMetric(ctx, def); err != nil {
|
||||
t.Fatalf("upsert %s: %v", def.Name, err)
|
||||
}
|
||||
}
|
||||
summary, err := GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize definitions: %v", err)
|
||||
}
|
||||
if !summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if _, err := s.SetMetricRetention(ctx, "short", 0); err != nil {
|
||||
t.Fatalf("disable short metric: %v", err)
|
||||
}
|
||||
summary, err = GetRetentionSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summarize disabled metric: %v", err)
|
||||
}
|
||||
if summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected disabled summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRetentionDefinitionsRequiresEveryMetricToBePositive(t *testing.T) {
|
||||
summary := summarizeRetentionDefinitions([]metric.Definition{
|
||||
{Name: "enabled", RetentionDays: 30},
|
||||
{Name: "disabled", RetentionDays: 0},
|
||||
{Name: "long", RetentionDays: 60},
|
||||
})
|
||||
if summary.AllPositive || summary.MaxDays != 60 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactCleansPointsOutsideFixedRawWindow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1), metric.WithRollupPolicy(metric.RollupPolicy{})))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{
|
||||
Name: "raw.metric",
|
||||
Type: metric.TypeGauge,
|
||||
RetentionDays: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert metric: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-11 * time.Minute), Value: 1},
|
||||
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-30 * time.Second), Value: 2},
|
||||
}); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if _, err := Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: "raw.metric",
|
||||
EntityID: "node",
|
||||
Start: now.Add(-time.Hour),
|
||||
End: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query points: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != 2 {
|
||||
t.Fatalf("expected only the retained raw point, got %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionCleanupReportsDeleteFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dsn := filepath.Join(t.TempDir(), "compact.db")
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
for _, name := range []string{"a.invalid", "b.healthy"} {
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
t.Fatalf("create metric %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
old := now.Add(-48 * time.Hour)
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: "a.invalid", EntityID: "node", Timestamp: old, Value: 1},
|
||||
{MetricName: "b.healthy", EntityID: "node", Timestamp: old, Value: 2},
|
||||
}); err != nil {
|
||||
t.Fatalf("write compact fixtures: %v", err)
|
||||
}
|
||||
|
||||
rawDB, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open raw sqlite connection: %v", err)
|
||||
}
|
||||
_, err = rawDB.ExecContext(ctx, `CREATE TRIGGER fail_invalid_metric_rollup
|
||||
BEFORE DELETE ON metric_rollups
|
||||
WHEN OLD.series_id IN (SELECT id FROM metric_series WHERE metric_name = 'a.invalid')
|
||||
BEGIN SELECT RAISE(FAIL, 'forced compact failure'); END`)
|
||||
_ = rawDB.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("create compact failure trigger: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
previousStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMu.Lock()
|
||||
store = previousStore
|
||||
storeMu.Unlock()
|
||||
})
|
||||
|
||||
if _, err := CleanupExpired(ctx, now); err == nil {
|
||||
t.Fatal("expected retention cleanup to report the forced delete failure")
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: "b.healthy",
|
||||
EntityID: "node",
|
||||
Start: old.Add(-time.Minute),
|
||||
End: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query healthy rollups: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("retention cleanup unexpectedly changed healthy metric data: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
ts := now.Add(-time.Hour)
|
||||
rec := models.Record{
|
||||
Client: "node-a",
|
||||
Time: ts,
|
||||
Cpu: 42.5,
|
||||
Ram: 123456,
|
||||
RamTotal: 999999,
|
||||
Disk: 456789,
|
||||
DiskTotal: 777777,
|
||||
Load: 0.75,
|
||||
Connections: 321,
|
||||
ConnectionsUdp: 12,
|
||||
}
|
||||
if _, err := WriteReport(ctx, v2.Report{
|
||||
UUID: rec.Client,
|
||||
UpdatedAt: ts,
|
||||
CPU: v2.CPUReport{Usage: float64(rec.Cpu)},
|
||||
Ram: v2.RamReport{Used: rec.Ram, Total: rec.RamTotal},
|
||||
Load: v2.LoadReport{Load1: float64(rec.Load)},
|
||||
Disk: v2.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
|
||||
Process: rec.Process,
|
||||
Connections: v2.ConnectionsReport{
|
||||
TCP: rec.Connections,
|
||||
UDP: rec.ConnectionsUdp,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact raw into rollup: %v", err)
|
||||
}
|
||||
got, err := GetRecordsByClientAndTime(ctx, rec.Client, ts.Add(-time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get records: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 reconstructed record from rollup, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Cpu == 0 || got[0].Ram == 0 || got[0].Disk == 0 || got[0].Connections == 0 {
|
||||
t.Fatalf("record was not reconstructed from rollup: %#v", got[0])
|
||||
}
|
||||
|
||||
all, err := GetRecordsByTime(ctx, ts.Add(-time.Minute), now)
|
||||
if err != nil {
|
||||
t.Fatalf("get all records: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].Client != rec.Client || all[0].Cpu == 0 {
|
||||
t.Fatalf("all-client records were not reconstructed from rollup: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecordMetricMaxByClientAndTimeQueriesOnlySelectedMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(-time.Minute)
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: "node-a", Timestamp: base.Add(10 * time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: "node-a", Timestamp: base.Add(20 * time.Second), Value: 90},
|
||||
{MetricName: MetricRAM, EntityID: "node-a", Timestamp: base.Add(20 * time.Second), Value: 123456},
|
||||
}); err != nil {
|
||||
t.Fatalf("write metric points: %v", err)
|
||||
}
|
||||
|
||||
got, err := getRecordMetricMaxByClientAndTimeFromSeries(ctx, s, "node-a", "cpu", base, base.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("get CPU max records: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("record count = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Cpu != 90 {
|
||||
t.Fatalf("CPU max = %v, want 90", got[0].Cpu)
|
||||
}
|
||||
if got[0].Ram != 0 {
|
||||
t.Fatalf("unselected RAM value = %d, want 0", got[0].Ram)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
)
|
||||
|
||||
const storeMigrationBatchSize = 500
|
||||
|
||||
// configFromFingerprint 从目标指纹(driver|dsn)重建一个 MetricStoreConfig,
|
||||
// 用于以只读方式打开“上一次的 metrics 目标库”。表前缀、保留天数、连接数等
|
||||
// 沿用当前配置(切换后端时这些通常不变)。
|
||||
func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*MetricStoreConfig, error) {
|
||||
idx := strings.Index(fingerprint, "|")
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("invalid target fingerprint: %q", fingerprint)
|
||||
}
|
||||
driver := fingerprint[:idx]
|
||||
dsn := fingerprint[idx+1:]
|
||||
if strings.TrimSpace(driver) == "" {
|
||||
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
|
||||
}
|
||||
return &MetricStoreConfig{
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
TablePrefix: base.TablePrefix,
|
||||
MaxOpenConns: base.MaxOpenConns,
|
||||
MaxIdleConns: base.MaxIdleConns,
|
||||
RollupMinuteRetentionMinutes: base.RollupMinuteRetentionMinutes,
|
||||
RollupFiveMinuteRetentionMinutes: base.RollupFiveMinuteRetentionMinutes,
|
||||
RollupHourRetentionHours: base.RollupHourRetentionHours,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openSourceStore 打开一个已存在的 metrics 目标库作为数据搬运的源库读取。
|
||||
//
|
||||
// 使用 autoMigrate=true 确保规范化表和索引存在;创建语句不会删除已有数据。
|
||||
// 当源库文件/表不存在(例如老快照记录了 completed 但 metrics.db 缺失)时,
|
||||
// 创建空表可让后续 ListMetrics 返回空集,把“无历史可迁移”识别为正常情况。
|
||||
func openSourceStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metric.Open(ctx, metricCfg)
|
||||
}
|
||||
|
||||
// defaultSQLiteFingerprint 返回默认 SQLite metrics 库(./data/metrics.db)的目标指纹。
|
||||
// 老快照的 metrics 数据固定落在该 SQLite 文件,用于在缺失指纹时推断上一个源库。
|
||||
func defaultSQLiteFingerprint() string {
|
||||
return targetFingerprint(&MetricStoreConfig{Driver: "sqlite", DSN: "./data/metrics.db"})
|
||||
}
|
||||
|
||||
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
|
||||
// - currentMetric:当前正在搬运的指标名。
|
||||
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
|
||||
// - totalMetrics:指标定义总数。
|
||||
// - addedPoints:本次新写入目标库的 rollup 行数(用于外部累计)。
|
||||
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
|
||||
|
||||
// 观察者 observe(为 nil 时行为与旧版本完全一致)。WebUI/API 手动触发的迁移传入
|
||||
// 回调以实时更新进度。
|
||||
func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe storeMigrationObserver) (int64, error) {
|
||||
if src == nil || dst == nil {
|
||||
return 0, fmt.Errorf("source or destination metric store is nil")
|
||||
}
|
||||
|
||||
defs, err := src.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list source metrics: %w", err)
|
||||
}
|
||||
|
||||
var total int64
|
||||
for i, def := range defs {
|
||||
if observe != nil {
|
||||
// 进入下一个指标:已完成 i 个指标。
|
||||
observe(def.Name, i, len(defs), 0)
|
||||
}
|
||||
// 目标库先建立指标定义,保证后续写入的指标存在。
|
||||
if err := dst.UpsertMetric(ctx, def); err != nil {
|
||||
return total, fmt.Errorf("upsert metric %q on target: %w", def.Name, err)
|
||||
}
|
||||
if def.RetentionDays == 0 {
|
||||
continue
|
||||
}
|
||||
var migrated int64
|
||||
_, err := src.ExportRollups(ctx, def.Name, storeMigrationBatchSize, func(batch []metric.PersistedRollup) error {
|
||||
if err := dst.ImportRollups(ctx, batch); err != nil {
|
||||
return fmt.Errorf("write metric %q rollup batch to target: %w", def.Name, err)
|
||||
}
|
||||
count := int64(len(batch))
|
||||
migrated += count
|
||||
total += count
|
||||
if observe != nil {
|
||||
observe(def.Name, i, len(defs), count)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("export metric %q rollups: %w", def.Name, err)
|
||||
}
|
||||
if migrated > 0 {
|
||||
logger.Infof("metricstore", "[store-migration] metric %q: migrated %d rollups", def.Name, migrated)
|
||||
}
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// storeOperationGate keeps an active Store stable while allowing regular
|
||||
// reads, report writes, and compaction to proceed together. Store replacement
|
||||
// and maintenance take the whole gate exclusively.
|
||||
type storeOperationGate struct {
|
||||
semaphore *semaphore.Weighted
|
||||
}
|
||||
|
||||
const storeOperationGateWeight int64 = 1024
|
||||
|
||||
func newStoreOperationGate() *storeOperationGate {
|
||||
return &storeOperationGate{semaphore: semaphore.NewWeighted(storeOperationGateWeight)}
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) Acquire(ctx context.Context) error {
|
||||
return g.semaphore.Acquire(ctx, storeOperationGateWeight)
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) TryAcquire() bool {
|
||||
return g.semaphore.TryAcquire(storeOperationGateWeight)
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) Release() {
|
||||
g.semaphore.Release(storeOperationGateWeight)
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) AcquireShared(ctx context.Context) error {
|
||||
return g.semaphore.Acquire(ctx, 1)
|
||||
}
|
||||
|
||||
func (g *storeOperationGate) ReleaseShared() {
|
||||
g.semaphore.Release(1)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// WritePingRecord 将 ping 记录写入 metric store
|
||||
func WritePingRecord(ctx context.Context, rec models.PingRecord) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker != nil {
|
||||
return worker.enqueuePing(ctx, rec)
|
||||
}
|
||||
return writePingRecords(ctx, []models.PingRecord{rec})
|
||||
}
|
||||
|
||||
func writePingRecords(ctx context.Context, records []models.PingRecord) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
points := make([]metric.Point, 0, len(records)*2)
|
||||
for _, rec := range records {
|
||||
tags := map[string]string{"task_id": fmt.Sprintf("%d", rec.TaskId)}
|
||||
loss := 0.0
|
||||
if rec.Value < 0 {
|
||||
loss = 1
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: rec.Time,
|
||||
Value: float64(rec.Value),
|
||||
Tags: tags,
|
||||
},
|
||||
metric.Point{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: rec.Time,
|
||||
Value: loss,
|
||||
Tags: tags,
|
||||
},
|
||||
)
|
||||
}
|
||||
return s.WriteBatch(ctx, points)
|
||||
}
|
||||
|
||||
// GetPingRecords returns legacy-shaped ping records, newest first.
|
||||
func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
query := MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
CompactDefinitions: true,
|
||||
Entities: EntityScope{All: clientUUID == ""},
|
||||
Specs: []MetricSpec{{Name: MetricPingLatency, Aggregations: []metric.Aggregation{metric.AggLast}}},
|
||||
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupSeries,
|
||||
Budget: PointBudget{PerSeries: 4000}, Rounding: RoundFloor, AllowMissing: true,
|
||||
}
|
||||
if clientUUID != "" {
|
||||
query.Entities.IDs = []string{clientUUID}
|
||||
}
|
||||
if taskID >= 0 {
|
||||
query.Tags = map[string]string{"task_id": fmt.Sprintf("%d", taskID)}
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
points := loaded.Values[MetricPingLatency][metric.AggLast]
|
||||
records := make([]models.PingRecord, 0, len(points))
|
||||
for _, p := range points {
|
||||
taskIDVal := uint(0)
|
||||
if tid, ok := p.Tags["task_id"]; ok {
|
||||
var t uint64
|
||||
fmt.Sscanf(tid, "%d", &t)
|
||||
taskIDVal = uint(t)
|
||||
}
|
||||
|
||||
records = append(records, models.PingRecord{
|
||||
Client: p.EntityID,
|
||||
TaskId: taskIDVal,
|
||||
Time: p.Bucket.UTC(),
|
||||
Value: int(p.Value),
|
||||
})
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].Time.After(records[j].Time)
|
||||
})
|
||||
|
||||
return records, nil
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// QueryMode selects exact samples, adaptive series, or exclusive-boundary lookups.
|
||||
type QueryMode string
|
||||
|
||||
const (
|
||||
QueryPoints QueryMode = "points"
|
||||
QuerySeries QueryMode = "series"
|
||||
QueryLatestBefore QueryMode = "latest_before"
|
||||
)
|
||||
|
||||
type QuerySource string
|
||||
|
||||
const (
|
||||
SourceAuto QuerySource = "auto"
|
||||
SourceRaw QuerySource = "raw"
|
||||
SourceRollup QuerySource = "rollup"
|
||||
)
|
||||
|
||||
type QueryGrouping string
|
||||
|
||||
const (
|
||||
GroupMerged QueryGrouping = "merged"
|
||||
GroupEntity QueryGrouping = "entity"
|
||||
GroupSeries QueryGrouping = "series"
|
||||
)
|
||||
|
||||
type IntervalRounding string
|
||||
|
||||
const (
|
||||
RoundFloor IntervalRounding = "floor"
|
||||
RoundCeil IntervalRounding = "ceil"
|
||||
)
|
||||
|
||||
// EntityScope deliberately distinguishes an empty authorized set from all entities.
|
||||
// All and IDs are mutually exclusive. IDs are deduplicated without modifying callers.
|
||||
type EntityScope struct {
|
||||
All bool
|
||||
IDs []string
|
||||
}
|
||||
type MetricSpec struct {
|
||||
Name string
|
||||
Aggregations []metric.Aggregation
|
||||
// Positive intervals are explicit and are preserved, even for historical data.
|
||||
Interval time.Duration
|
||||
// Zero inherits Budget.PerSeries, then 500. -1 disables adaptive downsampling:
|
||||
// a zero Interval then selects the finest compatible tier, not a whole-window bucket.
|
||||
MaxPoints int
|
||||
}
|
||||
|
||||
// PointBudget controls adaptive interval planning, not a raw-row SQL LIMIT.
|
||||
// Total divides the point target across entities (metrics may be columns of one
|
||||
// legacy record); it is not a hard cap across metrics, aggregations or tag variants.
|
||||
// Raw queries and explicit intervals retain their exact historical semantics.
|
||||
// Response adapters can impose their existing final sampling policy separately.
|
||||
type PointBudget struct {
|
||||
Total int
|
||||
PerSeries int
|
||||
}
|
||||
type FillPolicy struct {
|
||||
Gaps bool
|
||||
PingNegativeAsNull bool
|
||||
}
|
||||
|
||||
// QueryProjection controls retained result representations. The default keeps all
|
||||
// source data for compatibility; chart requires Fill and retains only Filled.
|
||||
type QueryProjection string
|
||||
|
||||
const (
|
||||
ProjectionFull QueryProjection = "full"
|
||||
ProjectionChart QueryProjection = "chart"
|
||||
)
|
||||
|
||||
type MetricsQuery struct {
|
||||
// Output omissions are opt-in; filtering and aggregation identity are unchanged.
|
||||
OmitTags bool
|
||||
OmitLabels bool
|
||||
CompactDefinitions bool
|
||||
Projection QueryProjection
|
||||
Store *metric.Store
|
||||
Mode QueryMode
|
||||
Source QuerySource
|
||||
Entities EntityScope
|
||||
Specs []MetricSpec
|
||||
Start, End time.Time
|
||||
Tags map[string]string
|
||||
Order metric.Order
|
||||
Grouping QueryGrouping
|
||||
Budget PointBudget
|
||||
Rounding IntervalRounding
|
||||
Fill FillPolicy
|
||||
AllowMissing bool
|
||||
Now time.Time
|
||||
}
|
||||
type MetricsResult struct {
|
||||
Definitions map[string]metric.Definition
|
||||
Raw map[string][]metric.Point
|
||||
Values map[string]map[metric.Aggregation][]metric.AggregatePoint
|
||||
Intervals map[string]time.Duration
|
||||
IsRaw bool
|
||||
Filled []MetricSeries
|
||||
// Latest is keyed by metric name, then entity; missing samples are absent.
|
||||
Latest map[string]map[string]metric.Point
|
||||
}
|
||||
type MetricSeries struct {
|
||||
MetricName, EntityID string
|
||||
Aggregation metric.Aggregation
|
||||
Tags map[string]string
|
||||
Interval time.Duration
|
||||
Points []MetricValue
|
||||
}
|
||||
type MetricValue struct {
|
||||
Time time.Time
|
||||
Value *float64
|
||||
Count int
|
||||
Tags, Labels map[string]string
|
||||
}
|
||||
|
||||
// DownsampleInterval returns a standard interval that keeps rangeDuration
|
||||
// within about maxPoints buckets, never finer than one second. roundUp is used
|
||||
// by point budgets: flooring can exceed the budget, while compatibility queries
|
||||
// preserve the historical floor behavior.
|
||||
func DownsampleInterval(rangeDuration time.Duration, maxPoints int, roundUp bool) time.Duration {
|
||||
if maxPoints < 1 {
|
||||
maxPoints = 1
|
||||
}
|
||||
points := time.Duration(maxPoints)
|
||||
interval := (rangeDuration + points - 1) / points
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if roundUp {
|
||||
return metric.CeilStandardInterval(interval)
|
||||
}
|
||||
return metric.FloorStandardInterval(interval)
|
||||
}
|
||||
|
||||
// QueryMetrics is the shared query policy boundary. Raw and aggregate slices are
|
||||
// transferred directly from batch primitives; nullable chart data is allocated only
|
||||
// when explicitly requested by Fill. All work uses ctx and one captured clock.
|
||||
func QueryMetrics(ctx context.Context, req MetricsQuery) (MetricsResult, error) {
|
||||
if ctx == nil {
|
||||
return MetricsResult{}, fmt.Errorf("%w: context is required", metric.ErrInvalidArgument)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return MetricsResult{}, err
|
||||
}
|
||||
result := MetricsResult{Definitions: map[string]metric.Definition{}, Raw: map[string][]metric.Point{}, Values: map[string]map[metric.Aggregation][]metric.AggregatePoint{}, Intervals: map[string]time.Duration{}, Latest: map[string]map[string]metric.Point{}}
|
||||
if req.Mode == "" {
|
||||
req.Mode = QuerySeries
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = SourceAuto
|
||||
}
|
||||
if req.Grouping == "" {
|
||||
req.Grouping = GroupMerged
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = metric.OrderAsc
|
||||
}
|
||||
if req.Rounding == "" {
|
||||
req.Rounding = RoundFloor
|
||||
}
|
||||
if err := validateMetricsQuery(req); err != nil {
|
||||
return result, err
|
||||
}
|
||||
now := req.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
req.Start, req.End = req.Start.UTC(), req.End.UTC()
|
||||
req.Entities.IDs = uniqueQueryIDs(req.Entities.IDs)
|
||||
// Never let the primitive's empty entity filter broaden an empty authorized set.
|
||||
|
||||
s := req.Store
|
||||
if s == nil {
|
||||
s = GetStore()
|
||||
}
|
||||
if s == nil {
|
||||
return result, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
names := make([]string, 0, len(req.Specs))
|
||||
for _, spec := range req.Specs {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
loadDefinitions := s.GetMetrics
|
||||
if req.CompactDefinitions {
|
||||
loadDefinitions = s.GetMetricsCompact
|
||||
}
|
||||
if !req.Entities.All && len(req.Entities.IDs) == 0 {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if req.Mode == QueryLatestBefore {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
continue
|
||||
}
|
||||
ids := req.Entities.IDs
|
||||
|
||||
for _, id := range ids {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
point, found, err := s.LatestBefore(ctx, name, id, req.End)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if found {
|
||||
if result.Latest[name] == nil {
|
||||
result.Latest[name] = map[string]metric.Point{}
|
||||
}
|
||||
result.Latest[name][id] = point
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
|
||||
if result.IsRaw {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
known := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; ok {
|
||||
known = append(known, name)
|
||||
result.Intervals[name] = 0
|
||||
}
|
||||
}
|
||||
if len(known) > 0 {
|
||||
result.Raw, err = s.QueryBatch(ctx, metric.BatchQuery{MetricNames: known, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, OmitLabels: req.OmitLabels, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entityCount := len(req.Entities.IDs)
|
||||
if needsTotalEntityCount(req) {
|
||||
if req.Entities.All {
|
||||
count, err := s.CountEntities(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
entityCount = count
|
||||
}
|
||||
// Explicit IDs are a conservative dictionary-count upper bound; never
|
||||
// scan every metric/tier simply to plan a response budget.
|
||||
}
|
||||
if entityCount < 1 {
|
||||
entityCount = 1
|
||||
}
|
||||
specs := make([]metric.BatchSeriesSpec, 0, len(req.Specs))
|
||||
for _, spec := range req.Specs {
|
||||
interval := spec.Interval
|
||||
if interval == 0 {
|
||||
points := spec.MaxPoints
|
||||
if points == 0 {
|
||||
points = req.Budget.PerSeries
|
||||
}
|
||||
if points == 0 {
|
||||
points = 500
|
||||
}
|
||||
if points == 500 && spec.MaxPoints == 0 && req.Budget.PerSeries == 0 && req.Budget.Total == -1 {
|
||||
points = -1
|
||||
}
|
||||
interval = time.Second
|
||||
if points != -1 {
|
||||
if req.Budget.Total > 0 {
|
||||
budget := req.Budget.Total / entityCount
|
||||
if budget < 1 {
|
||||
budget = 1
|
||||
}
|
||||
if budget < points {
|
||||
points = budget
|
||||
}
|
||||
}
|
||||
interval = DownsampleInterval(req.End.Sub(req.Start), points, req.Rounding == RoundCeil)
|
||||
}
|
||||
interval = s.CompatibleSeriesInterval(req.Start, now, interval)
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
}
|
||||
aggregations := spec.Aggregations
|
||||
if len(aggregations) == 0 {
|
||||
aggregations = []metric.Aggregation{metric.AggAvg}
|
||||
}
|
||||
specs = append(specs, metric.BatchSeriesSpec{MetricName: spec.Name, Aggregations: aggregations, Interval: interval, PreserveSeries: req.Grouping == GroupSeries, GroupByEntity: req.Grouping == GroupEntity})
|
||||
result.Intervals[spec.Name] = interval
|
||||
}
|
||||
batch, err := s.SeriesBatch(ctx, metric.BatchSeriesQuery{Specs: specs, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, CompactDefinitions: req.CompactDefinitions, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull}, now)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions, result.Values = batch.Definitions, batch.Values
|
||||
// SeriesBatch already loads metadata. Validate its result rather than issue a
|
||||
// second metadata query; unknown metrics otherwise look like empty series.
|
||||
if err := checkQueryDefinitions(names, batch.Definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := batch.Definitions[name]; !ok {
|
||||
delete(result.Intervals, name)
|
||||
delete(result.Values, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Fill.Gaps || req.Fill.PingNegativeAsNull {
|
||||
filled, err := buildQuerySeriesOwned(ctx, req, result, req.Projection == ProjectionChart)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Filled = filled
|
||||
if req.Projection == ProjectionChart {
|
||||
result.Raw, result.Values = nil, nil
|
||||
}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
// needsTotalEntityCount excludes explicit intervals and unlimited specs: neither
|
||||
// consumes the total adaptive budget. Keep its precedence aligned with planning.
|
||||
func needsTotalEntityCount(req MetricsQuery) bool {
|
||||
if req.Budget.Total <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, spec := range req.Specs {
|
||||
if spec.Interval != 0 {
|
||||
continue
|
||||
}
|
||||
points := spec.MaxPoints
|
||||
if points == 0 {
|
||||
points = req.Budget.PerSeries
|
||||
}
|
||||
if points != -1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateMetricsQuery(req MetricsQuery) error {
|
||||
invalid := func(message string) error { return fmt.Errorf("%w: %s", metric.ErrInvalidArgument, message) }
|
||||
if req.OmitTags && (req.Fill.Gaps || req.Fill.PingNegativeAsNull) {
|
||||
return invalid("omitting tags with filled output is unsupported: series identity must be preserved")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && (req.OmitTags || req.OmitLabels) {
|
||||
return invalid("latest-before output omissions are unsupported")
|
||||
}
|
||||
switch req.Mode {
|
||||
case QueryPoints, QuerySeries, QueryLatestBefore:
|
||||
default:
|
||||
return invalid("unsupported query mode")
|
||||
}
|
||||
switch req.Source {
|
||||
case SourceAuto, SourceRaw, SourceRollup:
|
||||
default:
|
||||
return invalid("unsupported query source")
|
||||
}
|
||||
switch req.Grouping {
|
||||
case GroupMerged, GroupEntity, GroupSeries:
|
||||
default:
|
||||
return invalid("unsupported query grouping")
|
||||
}
|
||||
switch req.Projection {
|
||||
case "", ProjectionFull:
|
||||
case ProjectionChart:
|
||||
if req.Mode == QueryLatestBefore || (!req.Fill.Gaps && !req.Fill.PingNegativeAsNull) {
|
||||
return invalid("chart projection requires fill and does not support latest-before")
|
||||
}
|
||||
default:
|
||||
return invalid("unsupported query projection")
|
||||
}
|
||||
switch req.Rounding {
|
||||
case RoundFloor, RoundCeil:
|
||||
default:
|
||||
return invalid("unsupported interval rounding")
|
||||
}
|
||||
if req.Order != metric.OrderAsc && req.Order != metric.OrderDesc {
|
||||
return invalid("unsupported query order")
|
||||
}
|
||||
if req.Entities.All && len(req.Entities.IDs) > 0 {
|
||||
return invalid("all entities and explicit IDs are mutually exclusive")
|
||||
}
|
||||
for _, id := range req.Entities.IDs {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return invalid("entity ID cannot be empty")
|
||||
}
|
||||
}
|
||||
if req.End.IsZero() {
|
||||
return invalid("end time is required")
|
||||
}
|
||||
if req.Mode != QueryLatestBefore && (req.Start.IsZero() || req.End.Before(req.Start)) {
|
||||
return invalid("valid start and end times are required")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && (len(req.Tags) > 0 || req.Source != SourceAuto) {
|
||||
return invalid("latest-before requires auto source and does not support tag filters")
|
||||
}
|
||||
if req.Mode == QueryPoints && req.Source == SourceRollup {
|
||||
return invalid("points mode requires raw or auto source")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && req.Entities.All {
|
||||
return invalid("latest-before requires explicit entity IDs")
|
||||
}
|
||||
if req.Budget.Total < -1 || req.Budget.PerSeries < -1 {
|
||||
return invalid("invalid point budget")
|
||||
}
|
||||
if len(req.Specs) == 0 {
|
||||
return invalid("at least one metric specification is required")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, spec := range req.Specs {
|
||||
if strings.TrimSpace(spec.Name) == "" || seen[spec.Name] {
|
||||
return invalid("metric names must be nonempty and unique")
|
||||
}
|
||||
seen[spec.Name] = true
|
||||
if spec.Interval < 0 || spec.MaxPoints < -1 {
|
||||
return invalid("invalid metric interval or point budget")
|
||||
}
|
||||
for _, agg := range spec.Aggregations {
|
||||
// Reuse primitive aggregation validation even when the selected source is raw.
|
||||
if err := (metric.AggregateQuery{Query: metric.Query{MetricName: spec.Name, Start: req.End, End: req.End}, Aggregation: agg, Interval: time.Second}).Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsesRawWindow selects the public adaptive raw window; its end boundary is strict.
|
||||
func UsesRawWindow(start, end, now time.Time) bool {
|
||||
return end.Sub(start) <= 10*time.Minute && end.After(now.Add(-10*time.Minute))
|
||||
}
|
||||
|
||||
func uniqueQueryIDs(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
out = append(out, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func checkQueryDefinitions(names []string, definitions map[string]metric.Definition, allowMissing bool) error {
|
||||
if !allowMissing {
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
return fmt.Errorf("%w: metric %q", metric.ErrNotFound, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// querySeriesKey consumes storage's immutable, collision-free output identity.
|
||||
// Manually constructed points (and low-level queries without the opt-in) retain
|
||||
// the old canonical fallback. Neither grouping nor ordering uses a hash.
|
||||
func querySeriesKey(entity string, tags map[string]string, identity *metric.SeriesIdentity) string {
|
||||
if key := identity.Key(); key != "" {
|
||||
return key
|
||||
}
|
||||
return metric.NewSeriesIdentity(entity, tags).Key()
|
||||
}
|
||||
|
||||
func buildQuerySeries(ctx context.Context, req MetricsQuery, result MetricsResult) ([]MetricSeries, error) {
|
||||
return buildQuerySeriesOwned(ctx, req, result, false)
|
||||
}
|
||||
|
||||
// detach is only valid for QueryMetrics-owned source maps. Release each source
|
||||
// array after conversion, before allocating gaps or processing the next metric.
|
||||
func buildQuerySeriesOwned(ctx context.Context, req MetricsQuery, result MetricsResult, detach bool) ([]MetricSeries, error) {
|
||||
out := []MetricSeries{}
|
||||
for _, spec := range req.Specs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := result.Definitions[spec.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
aggregations := spec.Aggregations
|
||||
if result.IsRaw {
|
||||
aggregations = []metric.Aggregation{""}
|
||||
} else if len(aggregations) == 0 {
|
||||
aggregations = []metric.Aggregation{metric.AggAvg}
|
||||
}
|
||||
for aggIndex, agg := range aggregations {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var seenEntities map[string]bool
|
||||
if len(req.Entities.IDs) > 0 && (result.IsRaw || req.Grouping != GroupMerged) {
|
||||
seenEntities = make(map[string]bool, len(req.Entities.IDs))
|
||||
}
|
||||
groups := map[string]*MetricSeries{}
|
||||
add := func(entity string, tags map[string]string, identity *metric.SeriesIdentity, value MetricValue) {
|
||||
// Exact samples always preserve identity; aggregate identity is determined by
|
||||
// the requested primitive grouping and must not be collapsed again here.
|
||||
key := querySeriesKey(entity, tags, identity)
|
||||
series := groups[key]
|
||||
if series == nil {
|
||||
series = &MetricSeries{MetricName: spec.Name, EntityID: entity, Aggregation: agg, Tags: tags, Interval: result.Intervals[spec.Name]}
|
||||
groups[key] = series
|
||||
if seenEntities != nil {
|
||||
seenEntities[entity] = true
|
||||
}
|
||||
}
|
||||
if req.Fill.PingNegativeAsNull && (spec.Name == MetricPingLatency || spec.Name == MetricPingLoss) && value.Value != nil && *value.Value == -1 {
|
||||
value.Value = nil
|
||||
}
|
||||
series.Points = append(series.Points, value)
|
||||
}
|
||||
if result.IsRaw {
|
||||
for i, p := range result.Raw[spec.Name] {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
v := p.Value
|
||||
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Timestamp, Value: &v, Count: 1, Tags: p.Tags, Labels: p.Labels})
|
||||
}
|
||||
} else {
|
||||
for i, p := range result.Values[spec.Name][agg] {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
v := p.Value
|
||||
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Bucket, Value: &v, Count: p.Count, Tags: p.Tags})
|
||||
}
|
||||
}
|
||||
if detach {
|
||||
if result.IsRaw {
|
||||
delete(result.Raw, spec.Name)
|
||||
} else {
|
||||
// Duplicate aggregation specs historically produce duplicate series.
|
||||
needed := false
|
||||
for _, next := range aggregations[aggIndex+1:] {
|
||||
needed = needed || next == agg
|
||||
}
|
||||
if !needed {
|
||||
delete(result.Values[spec.Name], agg)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed missing entities directly; do not scan all groups for each ID or
|
||||
// allocate a placeholder point only to discard it.
|
||||
seed := func(id string) {
|
||||
groups[querySeriesKey(id, req.Tags, nil)] = &MetricSeries{MetricName: spec.Name, EntityID: id, Aggregation: agg, Tags: req.Tags, Interval: result.Intervals[spec.Name]}
|
||||
if seenEntities != nil {
|
||||
seenEntities[id] = true
|
||||
}
|
||||
}
|
||||
if result.IsRaw || req.Grouping != GroupMerged {
|
||||
for i, id := range req.Entities.IDs {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !seenEntities[id] {
|
||||
seed(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
ids := req.Entities.IDs
|
||||
if len(ids) == 0 || (!result.IsRaw && req.Grouping == GroupMerged) {
|
||||
ids = []string{""}
|
||||
}
|
||||
for _, id := range ids {
|
||||
seed(id)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
if len(keys)%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series := *groups[key]
|
||||
sort.SliceStable(series.Points, func(i, j int) bool { return series.Points[i].Time.Before(series.Points[j].Time) })
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Fill.Gaps {
|
||||
var err error
|
||||
series, err = fillMetricSeriesOwned(ctx, series, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Order == metric.OrderDesc {
|
||||
for i, j := 0, len(series.Points)-1; i < j; i, j = i+1, j-1 {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
series.Points[i], series.Points[j] = series.Points[j], series.Points[i]
|
||||
}
|
||||
}
|
||||
out = append(out, series)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, ctx.Err()
|
||||
}
|
||||
|
||||
// FillMetricSeries matches the public chart's adaptive lower-quartile gap policy.
|
||||
func FillMetricSeries(series MetricSeries, start, end time.Time) MetricSeries {
|
||||
filled, _ := fillMetricSeries(context.Background(), series, start, end)
|
||||
return filled
|
||||
}
|
||||
|
||||
func fillMetricSeries(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
// Public callers retain ownership, including spare capacity. Never write there.
|
||||
series.Points = append([]MetricValue(nil), series.Points...)
|
||||
return fillMetricSeriesOwned(ctx, series, start, end)
|
||||
}
|
||||
|
||||
// fillMetricSeriesOwned inserts gaps backwards into exclusively owned storage.
|
||||
// Dense series reuse their point array; sparse series grow only when capacity
|
||||
// cannot accommodate the exact number of gaps, rather than always copying into
|
||||
// a second full point array. The public wrapper above remains non-mutating.
|
||||
func fillMetricSeriesOwned(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
deltas := make([]time.Duration, 0, len(series.Points))
|
||||
for i := 1; i < len(series.Points); i++ {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
if d := series.Points[i].Time.Sub(series.Points[i-1].Time); d > 0 {
|
||||
deltas = append(deltas, d)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
expected := series.Interval
|
||||
if len(deltas) >= 2 {
|
||||
sort.Slice(deltas, func(i, j int) bool { return deltas[i] < deltas[j] })
|
||||
if observed := deltas[(len(deltas)-1)/4]; observed > expected {
|
||||
expected = observed
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
if expected > 0 {
|
||||
series.Interval = expected
|
||||
}
|
||||
null := func(at time.Time) MetricValue { return MetricValue{Time: at.UTC(), Tags: series.Tags} }
|
||||
n := len(series.Points)
|
||||
if n == 0 {
|
||||
series.Points = append(series.Points, null(start), null(end))
|
||||
return series, ctx.Err()
|
||||
}
|
||||
leading := start.Before(series.Points[0].Time)
|
||||
gap := func(i int) bool {
|
||||
return i > 0 && expected > 0 && series.Points[i-1].Value != nil && series.Points[i].Value != nil && series.Points[i].Time.Sub(series.Points[i-1].Time) > expected+expected/2
|
||||
}
|
||||
extra := 0
|
||||
if leading {
|
||||
extra++
|
||||
}
|
||||
for i := 1; i < n; i++ {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
if gap(i) {
|
||||
extra++
|
||||
}
|
||||
}
|
||||
if extra == 0 {
|
||||
return series, ctx.Err()
|
||||
}
|
||||
if cap(series.Points) < n+extra {
|
||||
points := make([]MetricValue, n+extra)
|
||||
copy(points, series.Points)
|
||||
series.Points = points
|
||||
} else {
|
||||
series.Points = series.Points[:n+extra]
|
||||
}
|
||||
write := n + extra - 1
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
// Decide before moving: write may equal i, but never precedes it.
|
||||
insert := gap(i)
|
||||
var at time.Time
|
||||
if insert {
|
||||
at = series.Points[i-1].Time.Add(expected)
|
||||
}
|
||||
series.Points[write] = series.Points[i]
|
||||
write--
|
||||
if insert {
|
||||
series.Points[write] = null(at)
|
||||
write--
|
||||
}
|
||||
}
|
||||
if leading {
|
||||
series.Points[0] = null(start)
|
||||
}
|
||||
return series, ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// QueryContext runs a raw read query against the active metric store. The
|
||||
// caller must invoke the returned release function after closing rows.
|
||||
func QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, metric.Driver, func(), error) {
|
||||
return QueryForDriver(ctx, func(metric.Driver) (string, error) {
|
||||
return query, nil
|
||||
}, args...)
|
||||
}
|
||||
|
||||
// QueryForDriver runs a raw read query whose SQL depends on the active store's
|
||||
// driver. The caller must invoke the returned release function after closing rows.
|
||||
func QueryForDriver(ctx context.Context, query func(metric.Driver) (string, error), args ...any) (*sql.Rows, metric.Driver, func(), error) {
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return nil, "", nil, fmt.Errorf("wait for metric store operations before query: %w", err)
|
||||
}
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
statement, err := query(activeStore.Driver())
|
||||
if err != nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, err
|
||||
}
|
||||
rows, err := activeStore.QueryContext(ctx, statement, args...)
|
||||
if err != nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return rows, activeStore.Driver(), storeOperations.ReleaseShared, nil
|
||||
}
|
||||
|
||||
// ExecContext runs a raw statement against the active metric store.
|
||||
func ExecContext(ctx context.Context, query string, args ...any) (sql.Result, metric.Driver, error) {
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return nil, "", fmt.Errorf("wait for metric store operations before execution: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return nil, "", ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
result, err := activeStore.ExecContext(ctx, query, args...)
|
||||
return result, activeStore.Driver(), err
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func prepareRecoveryTest(t *testing.T) {
|
||||
t.Helper()
|
||||
configDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open config db: %v", err)
|
||||
}
|
||||
config.SetDb(configDB)
|
||||
|
||||
storeInitMu.Lock()
|
||||
storeMigMu.Lock()
|
||||
previousClosing := storeClosing
|
||||
storeClosing = false
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
previousStore := store
|
||||
previousFingerprint := storeFingerprint
|
||||
store = nil
|
||||
storeFingerprint = ""
|
||||
storeMu.Unlock()
|
||||
storeInitMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = CloseStoreContext(context.Background())
|
||||
storeMigMu.Lock()
|
||||
storeClosing = previousClosing
|
||||
storeMigMu.Unlock()
|
||||
storeMu.Lock()
|
||||
store = previousStore
|
||||
storeFingerprint = previousFingerprint
|
||||
storeMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecoverStoreFailureKeepsCurrentConfig(t *testing.T) {
|
||||
prepareRecoveryTest(t)
|
||||
if err := config.SetMany(map[string]any{
|
||||
MetricDBDriverKey: "sqlite",
|
||||
MetricDBDSNKey: "old.db",
|
||||
}); err != nil {
|
||||
t.Fatalf("save original config: %v", err)
|
||||
}
|
||||
|
||||
missing := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "missing.db")) + "?mode=ro"
|
||||
err := RecoverStore(context.Background(), &MetricStoreConfig{Driver: "sqlite", DSN: missing})
|
||||
if err == nil {
|
||||
t.Fatal("recovery unexpectedly opened a missing read-only database")
|
||||
}
|
||||
if got, _ := config.GetAs[string](MetricDBDSNKey, ""); got != "old.db" {
|
||||
t.Fatalf("DSN changed after failed recovery: %q", got)
|
||||
}
|
||||
if GetStore() != nil {
|
||||
t.Fatal("failed recovery installed a store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverStoreRecordsManualMigrationSource(t *testing.T) {
|
||||
prepareRecoveryTest(t)
|
||||
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "recovered.db"))
|
||||
t.Cleanup(func() { _ = CloseStoreContext(context.Background()) })
|
||||
cfg := &MetricStoreConfig{Driver: "sqlite", DSN: dsn}
|
||||
if err := RecoverStore(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("recover store: %v", err)
|
||||
}
|
||||
if GetStore() == nil {
|
||||
t.Fatal("successful recovery did not install a store")
|
||||
}
|
||||
wantTarget := targetFingerprint(cfg)
|
||||
if got, _ := config.GetAs[string](MigrationTargetKey, ""); got != wantTarget {
|
||||
t.Fatalf("migration target = %q, want %q", got, wantTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverStorePersistsLegacyStoreForStructureGuide(t *testing.T) {
|
||||
prepareRecoveryTest(t)
|
||||
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy store: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE metric_definitions (
|
||||
name TEXT PRIMARY KEY, type TEXT NOT NULL, unit TEXT NOT NULL,
|
||||
description TEXT NOT NULL, retention_days INTEGER NOT NULL,
|
||||
metadata TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("create legacy definition table: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close legacy store: %v", err)
|
||||
}
|
||||
|
||||
cfg := &MetricStoreConfig{Driver: "sqlite", DSN: dsn}
|
||||
if err := RecoverStore(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("recover legacy store: %v", err)
|
||||
}
|
||||
if GetStore() != nil {
|
||||
t.Fatal("legacy recovery installed the store before structure upgrade")
|
||||
}
|
||||
if got, _ := config.GetAs[string](MetricDBDSNKey, ""); got != dsn {
|
||||
t.Fatalf("saved legacy DSN = %q, want %q", got, dsn)
|
||||
}
|
||||
required, err := StructureUpgradeRequired(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("detect structure upgrade after recovery: %v", err)
|
||||
}
|
||||
if !required {
|
||||
t.Fatal("legacy recovery did not lead to the structure upgrade guide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadRejectsLegacyStoreForStructureUpgrade(t *testing.T) {
|
||||
prepareRecoveryTest(t)
|
||||
active, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open active metric store: %v", err)
|
||||
}
|
||||
installTestStore(t, active)
|
||||
|
||||
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy store: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE metric_definitions (
|
||||
name TEXT PRIMARY KEY, type TEXT NOT NULL, unit TEXT NOT NULL,
|
||||
description TEXT NOT NULL, retention_days INTEGER NOT NULL,
|
||||
metadata TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("create legacy definition table: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close legacy store: %v", err)
|
||||
}
|
||||
if err := config.SetMany(map[string]any{
|
||||
MetricDBDriverKey: "sqlite",
|
||||
MetricDBDSNKey: dsn,
|
||||
}); err != nil {
|
||||
t.Fatalf("save legacy metric store config: %v", err)
|
||||
}
|
||||
|
||||
err = Reload(context.Background())
|
||||
if !errors.Is(err, ErrStructureUpgradeRequired) {
|
||||
t.Fatalf("reload error = %v, want %v", err, ErrStructureUpgradeRequired)
|
||||
}
|
||||
if GetStore() != active {
|
||||
t.Fatal("legacy target replaced the active metric store during hot reload")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 462 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user