mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-06 14:10:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
name: Build frontend theme
|
||||
description: Clone komari-web, build the default theme, and optionally upload it as an artifact.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version used to build komari-web.
|
||||
default: "23"
|
||||
repository:
|
||||
description: komari-web repository URL.
|
||||
default: https://github.com/komari-monitor/komari-web
|
||||
ref:
|
||||
description: Optional komari-web ref to build. Uses the repository default branch when empty.
|
||||
default: ""
|
||||
artifact-name:
|
||||
description: Optional artifact name for the packed frontend theme.
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Clone and build frontend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
rm -rf komari-web
|
||||
if [ -n "${{ inputs.ref }}" ]; then
|
||||
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
|
||||
else
|
||||
git clone --depth=1 "${{ inputs.repository }}" komari-web
|
||||
fi
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Pack frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
tar -czf "$RUNNER_TEMP/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme .
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ inputs.artifact-name }}.tar.gz
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Restore frontend theme
|
||||
description: Download and unpack a previously built frontend theme artifact.
|
||||
|
||||
inputs:
|
||||
artifact-name:
|
||||
description: Frontend theme artifact name.
|
||||
default: komari-frontend
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/frontend-artifact
|
||||
|
||||
- name: Extract frontend artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p web/public/defaultTheme
|
||||
rm -rf web/public/defaultTheme/*
|
||||
tar -xzf "$RUNNER_TEMP/frontend-artifact/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Setup Zig
|
||||
description: Restore or install the Zig toolchain used for CGO cross-compilation.
|
||||
|
||||
inputs:
|
||||
zig-version:
|
||||
description: Zig version to install.
|
||||
default: 0.14.1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore Zig cache
|
||||
id: zig-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}
|
||||
key: zig-${{ runner.os }}-x86_64-${{ inputs.zig-version }}
|
||||
|
||||
- name: Download Zig
|
||||
if: ${{ steps.zig-cache.outputs.cache-hit != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p "$HOME/.cache/komari-zig"
|
||||
curl -fsSL "https://ziglang.org/download/${{ inputs.zig-version }}/zig-x86_64-linux-${{ inputs.zig-version }}.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
|
||||
tar -C "$HOME/.cache/komari-zig" -Jxf "$RUNNER_TEMP/zig.tar.xz"
|
||||
|
||||
- name: Add Zig to PATH
|
||||
shell: bash
|
||||
run: |
|
||||
echo "$HOME/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}" >> "$GITHUB_PATH"
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Auto Merge Dev to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize] # No longer need 'closed' trigger
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
# Job to enable Auto-Merge for the PR
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for open PRs from dev to main
|
||||
if: github.head_ref == 'dev' && github.base_ref == 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Required for git history
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Get PR number
|
||||
id: get-pr
|
||||
run: |
|
||||
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for conflicts
|
||||
id: check-conflicts
|
||||
run: |
|
||||
# Ensure local branches are up-to-date
|
||||
git fetch origin main
|
||||
git fetch origin dev
|
||||
|
||||
# Use merge-base to find the common ancestor and check for conflicts
|
||||
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "❌ Merge conflicts detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ No merge conflicts"
|
||||
fi
|
||||
|
||||
- name: Add conflict comment
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "❌ **Merge Conflict Detection**
|
||||
|
||||
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
|
||||
|
||||
Steps to resolve conflicts:
|
||||
1. \`git checkout dev\`
|
||||
2. \`git merge main\`
|
||||
3. Resolve conflicts
|
||||
4. \`git commit\`
|
||||
5. \`git push origin dev\`"
|
||||
exit 1 # Exit to prevent further steps
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Enable auto-merge using a merge commit
|
||||
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
|
||||
# Add success comment
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "✅ **Auto-merge Enabled**
|
||||
|
||||
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
|
||||
|
||||
Merge method: Merge commit
|
||||
Branch retention: dev branch will be preserved"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
|
||||
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
+24
-23
@@ -1,12 +1,26 @@
|
||||
name: Build Binaries on Main Push
|
||||
name: Build Binaries on PR
|
||||
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
@@ -45,6 +59,10 @@ jobs:
|
||||
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
|
||||
@@ -54,20 +72,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- 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/
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -75,12 +81,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
@@ -103,4 +104,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: 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,23 +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);
|
||||
|
||||
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
|
||||
const shaOnlyVersions = packages.filter(pkg => {
|
||||
// 筛选仅有 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}$/);
|
||||
return (
|
||||
(tags.length === 1 && (
|
||||
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
|
||||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
|
||||
)) ||
|
||||
tags.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
// Delete the SHA-only versions
|
||||
for (const version of shaOnlyVersions) {
|
||||
// 删除这些版本
|
||||
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} with SHA tag ${version.metadata.container.tags[0]}`);
|
||||
}
|
||||
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -12,22 +12,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
|
||||
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Publish Docker Image
|
||||
name: Publish Docker Image on Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -11,30 +11,28 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-multi-arch:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- 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
|
||||
|
||||
- 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-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
|
||||
@@ -42,10 +40,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
run: |
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
@@ -63,6 +58,29 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to regenerate notes for. Leave empty to use the latest release."
|
||||
required: false
|
||||
type: string
|
||||
base_ref:
|
||||
description: "Base ref or SHA for the release comparison. Snapshot builds pass the previous main SHA."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'Snapshot-') }}
|
||||
|
||||
steps:
|
||||
- name: Generate and apply release notes
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const webRepo = { owner: "komari-monitor", repo: "komari-web" };
|
||||
|
||||
const AI_CONTEXT_CHAR_LIMIT = 120000;
|
||||
const AI_MAX_COMMITS = 160;
|
||||
const AI_MAX_CHANGED_FILES = 120;
|
||||
const AI_MAX_PULL_REQUESTS = 80;
|
||||
const AI_MAX_PATCH_CHARS_PER_FILE = 2200;
|
||||
const OPENAI_TIMEOUT_MS = 120000;
|
||||
|
||||
const clean = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
const isSnapshotTag = (tag) => /^Snapshot-/i.test(clean(tag));
|
||||
const releaseTimestamp = (release) => Date.parse(release.published_at || release.created_at || "");
|
||||
const isStableRelease = (release) => !release.draft && !release.prerelease && !isSnapshotTag(release.tag_name);
|
||||
|
||||
function limitText(value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
|
||||
}
|
||||
|
||||
function appendWithinLimit(parts, value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentLength = parts.reduce((total, part) => total + part.length + 2, 0);
|
||||
if (currentLength + text.length + 2 <= maxLength) {
|
||||
parts.push(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
const remaining = maxLength - currentLength - 64;
|
||||
if (remaining > 500) {
|
||||
parts.push(`${text.slice(0, remaining)}\n...[context truncated]`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRelease() {
|
||||
const requestedTag = clean(context.payload.release?.tag_name)
|
||||
|| clean(context.payload.client_payload?.tag)
|
||||
|| clean(context.payload.inputs?.tag);
|
||||
|
||||
if (requestedTag) {
|
||||
const release = context.payload.release?.tag_name === requestedTag
|
||||
? context.payload.release
|
||||
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag: requestedTag })).data;
|
||||
return { tag: requestedTag, release };
|
||||
}
|
||||
|
||||
const release = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
|
||||
return { tag: release.tag_name, release };
|
||||
}
|
||||
|
||||
async function getPreviousReleaseTag(tag, release) {
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const currentTime = releaseTimestamp(release);
|
||||
const currentIsSnapshot = isSnapshotTag(tag);
|
||||
const currentIsStable = isStableRelease(release);
|
||||
const candidates = releases
|
||||
.filter((item) => !item.draft && item.tag_name !== tag)
|
||||
.filter((item) => {
|
||||
if (!Number.isFinite(currentTime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemTime = releaseTimestamp(item);
|
||||
return Number.isFinite(itemTime) && itemTime < currentTime;
|
||||
})
|
||||
.filter((item) => {
|
||||
if (currentIsSnapshot || currentIsStable) {
|
||||
return isStableRelease(item);
|
||||
}
|
||||
|
||||
return item.prerelease && !isSnapshotTag(item.tag_name);
|
||||
})
|
||||
.sort((left, right) => releaseTimestamp(right) - releaseTimestamp(left));
|
||||
|
||||
return candidates[0]?.tag_name || "";
|
||||
}
|
||||
|
||||
async function resolveComparisonBase(tag, release) {
|
||||
const requestedBaseRef = clean(context.payload.client_payload?.base_ref)
|
||||
|| clean(context.payload.inputs?.base_ref);
|
||||
|
||||
if (requestedBaseRef) {
|
||||
return {
|
||||
ref: requestedBaseRef,
|
||||
previousTag: "",
|
||||
source: "requested base ref",
|
||||
};
|
||||
}
|
||||
|
||||
const previousTag = await getPreviousReleaseTag(tag, release);
|
||||
if (previousTag) {
|
||||
return {
|
||||
ref: previousTag,
|
||||
previousTag,
|
||||
source: isSnapshotTag(tag) ? "previous stable release" : "previous release in the same channel",
|
||||
};
|
||||
}
|
||||
|
||||
if (isSnapshotTag(tag)) {
|
||||
try {
|
||||
const commit = (await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: release.target_commitish || tag,
|
||||
})).data;
|
||||
const parentSha = commit.parents?.[0]?.sha || "";
|
||||
if (parentSha) {
|
||||
return {
|
||||
ref: parentSha,
|
||||
previousTag: "",
|
||||
source: "snapshot target parent commit",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to resolve parent commit for ${tag}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ref: "",
|
||||
previousTag: "",
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeneratedNotes(tag, release, previousTag) {
|
||||
const params = {
|
||||
owner,
|
||||
repo,
|
||||
tag_name: tag,
|
||||
target_commitish: release.target_commitish || undefined,
|
||||
};
|
||||
|
||||
if (previousTag) {
|
||||
params.previous_tag_name = previousTag;
|
||||
}
|
||||
|
||||
const generated = await github.request(
|
||||
"POST /repos/{owner}/{repo}/releases/generate-notes",
|
||||
params,
|
||||
);
|
||||
|
||||
return clean(generated.data.body);
|
||||
}
|
||||
|
||||
async function getComparison(baseRef, tag) {
|
||||
if (!baseRef) {
|
||||
return { commits: [], files: [], htmlUrl: "" };
|
||||
}
|
||||
|
||||
const commits = [];
|
||||
let files = [];
|
||||
let htmlUrl = "";
|
||||
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const response = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseRef}...${tag}`,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
files = response.data.files || [];
|
||||
htmlUrl = response.data.html_url || "";
|
||||
}
|
||||
|
||||
commits.push(...response.data.commits);
|
||||
|
||||
if (!response.headers.link?.includes('rel="next"')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { commits, files, htmlUrl };
|
||||
}
|
||||
|
||||
function renderCommitLine(commit) {
|
||||
const subject = clean(commit.commit.message.split("\n")[0]) || "(no subject)";
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login
|
||||
? ` @${commit.author.login}`
|
||||
: (commit.commit.author?.name ? ` ${commit.commit.author.name}` : "");
|
||||
|
||||
return `- ${subject} ([${shortSha}](${commit.html_url}))${author}`;
|
||||
}
|
||||
|
||||
function buildWhatsChangedSection(baseRef, tag, comparison) {
|
||||
if (!baseRef) {
|
||||
return "## What's Changed\n\nNo comparison base was found for this release.";
|
||||
}
|
||||
|
||||
if (comparison.commits.length === 0) {
|
||||
return `## What's Changed\n\nNo commits found between \`${baseRef}\` and \`${tag}\`.`;
|
||||
}
|
||||
|
||||
const lines = ["## What's Changed", "", ...comparison.commits.map(renderCommitLine)];
|
||||
if (comparison.htmlUrl) {
|
||||
lines.push("", `**Full Changelog**: ${comparison.htmlUrl}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendCommitsToGeneratedNotes(generatedNotes, baseRef, tag, comparison) {
|
||||
const notes = clean(generatedNotes);
|
||||
if (!notes || comparison.commits.length === 0) {
|
||||
return notes || buildWhatsChangedSection(baseRef, tag, comparison);
|
||||
}
|
||||
|
||||
const lines = notes.split("\n");
|
||||
const fullChangelogIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/i.test(line.trim()));
|
||||
const nextTopLevelSectionIndex = lines.findIndex((line, index) => (
|
||||
index > 0
|
||||
&& /^##\s+/.test(line)
|
||||
&& !/^##\s+What's Changed\b/i.test(line)
|
||||
));
|
||||
const insertionIndex = nextTopLevelSectionIndex >= 0
|
||||
? nextTopLevelSectionIndex
|
||||
: (fullChangelogIndex >= 0 ? fullChangelogIndex : lines.length);
|
||||
const commitSection = [
|
||||
"### Commits",
|
||||
"",
|
||||
...comparison.commits.map(renderCommitLine),
|
||||
"",
|
||||
];
|
||||
|
||||
lines.splice(insertionIndex, 0, ...commitSection);
|
||||
return clean(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function getPullRequestContext(commits) {
|
||||
const pullRequests = new Map();
|
||||
|
||||
for (const commit of commits.slice(0, AI_MAX_COMMITS)) {
|
||||
try {
|
||||
const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: commit.sha,
|
||||
});
|
||||
|
||||
for (const pullRequest of response.data || []) {
|
||||
pullRequests.set(pullRequest.html_url, pullRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to load pull requests for ${commit.sha.slice(0, 7)}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (pullRequests.size >= AI_MAX_PULL_REQUESTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...pullRequests.values()].slice(0, AI_MAX_PULL_REQUESTS);
|
||||
}
|
||||
|
||||
function buildChangedFilesContext(files) {
|
||||
if (!files.length) {
|
||||
return "No changed file metadata was returned by GitHub.";
|
||||
}
|
||||
|
||||
return files.slice(0, AI_MAX_CHANGED_FILES).map((file) => {
|
||||
const lines = [
|
||||
`File: ${file.filename}`,
|
||||
`Status: ${file.status}; additions: ${file.additions}; deletions: ${file.deletions}; changes: ${file.changes}`,
|
||||
];
|
||||
|
||||
if (file.patch) {
|
||||
lines.push("Patch excerpt:");
|
||||
lines.push(limitText(file.patch, AI_MAX_PATCH_CHARS_PER_FILE));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildCommitContext(commits) {
|
||||
if (!commits.length) {
|
||||
return "No commits were returned by GitHub.";
|
||||
}
|
||||
|
||||
return commits.slice(0, AI_MAX_COMMITS).map((commit) => {
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login || commit.commit.author?.name || "unknown";
|
||||
const message = limitText(commit.commit.message, 1800);
|
||||
return [
|
||||
`Commit: ${shortSha}`,
|
||||
`Author: ${author}`,
|
||||
`URL: ${commit.html_url}`,
|
||||
"Message:",
|
||||
message,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildPullRequestContext(pullRequests) {
|
||||
if (!pullRequests.length) {
|
||||
return "No associated pull requests were found for the compared commits.";
|
||||
}
|
||||
|
||||
return pullRequests.map((pullRequest) => {
|
||||
const labels = (pullRequest.labels || []).map((label) => label.name).filter(Boolean).join(", ") || "none";
|
||||
return [
|
||||
`PR: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`URL: ${pullRequest.html_url}`,
|
||||
`Author: ${pullRequest.user?.login || "unknown"}`,
|
||||
`Labels: ${labels}`,
|
||||
"Body:",
|
||||
limitText(pullRequest.body, 1800) || "(empty)",
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiContext({ tag, release, comparisonBase, whatsChangedSection, comparison, pullRequests, webReleaseSection }) {
|
||||
const sections = [];
|
||||
|
||||
appendWithinLimit(sections, [
|
||||
`Repository: ${owner}/${repo}`,
|
||||
`Release tag: ${tag}`,
|
||||
`Release title: ${release.name || tag}`,
|
||||
`Prerelease: ${release.prerelease ? "yes" : "no"}`,
|
||||
`Snapshot tag: ${isSnapshotTag(tag) ? "yes" : "no"}`,
|
||||
`Target commitish: ${release.target_commitish || "(unknown)"}`,
|
||||
`Comparison base: ${comparisonBase.ref || "(none)"}`,
|
||||
`Comparison base source: ${comparisonBase.source}`,
|
||||
`Compare URL: ${comparison.htmlUrl || "(unavailable)"}`,
|
||||
].join("\n"), AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
appendWithinLimit(sections, `What's Changed section:\n${whatsChangedSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Changed files and patch excerpts:\n${buildChangedFilesContext(comparison.files)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Associated pull requests:\n${buildPullRequestContext(pullRequests)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Commit details:\n${buildCommitContext(comparison.commits)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
if (webReleaseSection) {
|
||||
appendWithinLimit(sections, `Komari Web release notes for the same tag:\n${webReleaseSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
}
|
||||
|
||||
return sections.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildChatCompletionsUrl(baseUrl) {
|
||||
const trimmed = clean(baseUrl).replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/chat/completions")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
return `${trimmed}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function normalizeReleaseSummary(markdown) {
|
||||
const summary = clean(markdown);
|
||||
if (!summary) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutAiHeading = clean(summary
|
||||
.replace(/^#{1,6}\s+AI Summary\b\s*/im, "")
|
||||
.replace(/^\*\*AI Summary\*\*\s*/im, ""));
|
||||
const whatsChangedIndex = withoutAiHeading.search(/^##\s+What's Changed\b/im);
|
||||
if (whatsChangedIndex >= 0) {
|
||||
return clean(withoutAiHeading.slice(0, whatsChangedIndex));
|
||||
}
|
||||
|
||||
return withoutAiHeading;
|
||||
}
|
||||
|
||||
async function callOpenAiChatCompletion(url, apiKey, model, messages) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model, messages, temperature: 0 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseJson = {};
|
||||
try {
|
||||
responseJson = responseText ? JSON.parse(responseText) : {};
|
||||
} catch {
|
||||
responseJson = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = clean(responseJson.error?.message) || limitText(responseText, 500);
|
||||
throw new Error(`OpenAI request failed with ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
return clean(responseJson.choices?.[0]?.message?.content);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReleaseSummarySection({ tag, release, comparisonBase, whatsChangedSection, comparison, webReleaseSection }) {
|
||||
const apiKey = clean(process.env.OPENAI_API_KEY);
|
||||
const baseUrl = clean(process.env.OPENAI_BASE_URL);
|
||||
const model = clean(process.env.OPENAI_MODEL);
|
||||
|
||||
if (!apiKey || !baseUrl || !model) {
|
||||
core.warning("OPENAI_API_KEY, OPENAI_BASE_URL, or OPENAI_MODEL is missing. Skipping generated release summary.");
|
||||
return "";
|
||||
}
|
||||
|
||||
const pullRequests = await getPullRequestContext(comparison.commits);
|
||||
const aiContext = buildAiContext({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
pullRequests,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"You write accurate release notes for the Komari server project.",
|
||||
"Use only the supplied repository context.",
|
||||
"Include both Komari server changes and Komari Web frontend changes when frontend release notes are supplied.",
|
||||
"Prioritize user-visible behavior, compatibility or migration notes, operations/deployment changes, security fixes, and important bug fixes.",
|
||||
"Do not invent features or implementation details that are not supported by the context.",
|
||||
"Write concise Markdown in Simplified Chinese.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
`Create the release summary section for release ${tag}.`,
|
||||
comparisonBase.ref
|
||||
? `Summarize only changes in the comparison ${comparisonBase.ref}...${tag}.`
|
||||
: "No comparison base was found, so summarize only the supplied release context.",
|
||||
"Return only Markdown for the summary section that appears before '## What's Changed'.",
|
||||
"Do not use the heading 'AI Summary'. Do not add a 'What's Changed' section.",
|
||||
"Use these top-level headings, in this order: '## 主要更新内容', '## Bug修复', and '## 其他'.",
|
||||
"Omit '## Bug修复' when there are no concrete bug fixes. Keep bullets short and specific.",
|
||||
"",
|
||||
aiContext,
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const url = buildChatCompletionsUrl(baseUrl);
|
||||
const summary = await callOpenAiChatCompletion(url, apiKey, model, messages);
|
||||
return normalizeReleaseSummary(summary);
|
||||
} catch (error) {
|
||||
core.warning(`Generated release summary failed: ${error.message}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWebReleaseSection(tag) {
|
||||
const demoteMarkdownHeadings = (markdown) => markdown.replace(/^(#{1,5})(\s+)/gm, "#$1$2");
|
||||
|
||||
try {
|
||||
const webRelease = (await github.rest.repos.getReleaseByTag({
|
||||
owner: webRepo.owner,
|
||||
repo: webRepo.repo,
|
||||
tag,
|
||||
})).data;
|
||||
|
||||
const webBody = clean(webRelease.body);
|
||||
if (!webBody) {
|
||||
return `## Komari Web Release Notes\n\nKomari Web release [${tag}](${webRelease.html_url}) exists, but its release notes are empty.`;
|
||||
}
|
||||
|
||||
return [
|
||||
"## Komari Web Release Notes",
|
||||
"",
|
||||
`Source: [komari-web ${tag}](${webRelease.html_url})`,
|
||||
"",
|
||||
demoteMarkdownHeadings(webBody),
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Komari Web release ${tag} does not exist yet. Skipping web release notes for now.`);
|
||||
return "";
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const { tag, release } = await resolveRelease();
|
||||
if (!tag) {
|
||||
core.setFailed("No release tag was provided or found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comparisonBase = await resolveComparisonBase(tag, release);
|
||||
core.info(comparisonBase.ref
|
||||
? `Comparison base for ${tag}: ${comparisonBase.ref} (${comparisonBase.source}).`
|
||||
: `No comparison base found for ${tag}.`);
|
||||
|
||||
const comparison = await getComparison(comparisonBase.ref, tag);
|
||||
const whatsChangedSection = comparisonBase.previousTag
|
||||
? appendCommitsToGeneratedNotes(
|
||||
await getGeneratedNotes(tag, release, comparisonBase.previousTag),
|
||||
comparisonBase.ref,
|
||||
tag,
|
||||
comparison,
|
||||
)
|
||||
: (!comparisonBase.ref && !isSnapshotTag(tag)
|
||||
? await getGeneratedNotes(tag, release, "")
|
||||
: buildWhatsChangedSection(comparisonBase.ref, tag, comparison));
|
||||
|
||||
if (!whatsChangedSection) {
|
||||
core.setFailed(`Failed to build What's Changed section for ${tag}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webReleaseSection = await buildWebReleaseSection(tag);
|
||||
const releaseSummarySection = await buildReleaseSummarySection({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const body = [
|
||||
releaseSummarySection,
|
||||
whatsChangedSection,
|
||||
].filter(Boolean).join("\n\n");
|
||||
|
||||
if (clean(release.body) === clean(body)) {
|
||||
core.info(`Release notes for ${tag} are already up to date.`);
|
||||
} else {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Release Notes")
|
||||
.addRaw(`Updated release notes for ${tag}.`)
|
||||
.write();
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Rebuild Existing Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing GitHub Release tag whose assets and version image will be replaced.
|
||||
required: true
|
||||
type: string
|
||||
server_ref:
|
||||
description: Server commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
frontend_ref:
|
||||
description: komari-web commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout workflow source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build selected frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ inputs.frontend_ref }}
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: komari-monitor/komari/.github/actions/restore-frontend@main
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: komari-monitor/komari/.github/actions/setup-zig@main
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
VERSION_HASH="$(git rev-parse HEAD)"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
|
||||
build-and-push-docker:
|
||||
needs: build-and-attach-binaries
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Build and push version Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -1,9 +1,12 @@
|
||||
name: Publish Docker Image
|
||||
name: Publish Docker Image Manually
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Docker image tag and embedded version.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,7 +17,61 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-docker-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,47 +81,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- 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/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
run: |
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -85,8 +106,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -96,3 +116,5 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
+114
-20
@@ -13,7 +13,38 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
|
||||
|
||||
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: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -52,6 +83,10 @@ jobs:
|
||||
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
|
||||
@@ -61,20 +96,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- 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/
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -82,12 +105,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
@@ -105,6 +123,12 @@ jobs:
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -117,3 +141,73 @@ jobs:
|
||||
# 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:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- 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 }}
|
||||
|
||||
- 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
|
||||
|
||||
- name: Promote Docker latest tag
|
||||
if: steps.release.outputs.is_latest == 'true'
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
@@ -0,0 +1,293 @@
|
||||
name: Publish Snapshot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-ref:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure snapshot target is main
|
||||
run: |
|
||||
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Snapshot builds must run from refs/heads/main. Current ref: ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot builds must use the current main commit."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Generate snapshot version
|
||||
id: version
|
||||
run: |
|
||||
# Snapshot 版本号格式: Snapshot-yymmddhhMM (UTC+8)
|
||||
VERSION="Snapshot-$(date -u -d '+8 hours' +%y%m%d%H%M)"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
|
||||
build-frontend:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: [prepare, build-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
publish-snapshot-release:
|
||||
needs: [prepare, build-and-attach-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download all binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create snapshot prerelease and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
ls -alR artifacts
|
||||
|
||||
# 如果同名 release/tag 已存在(例如重跑),先删除避免创建冲突
|
||||
if gh release view "${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release ${VERSION} 已存在,正在删除以重新创建..."
|
||||
gh release delete "${VERSION}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
fi
|
||||
|
||||
# 创建预发布并上传所有二进制文件
|
||||
gh release create "${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "${VERSION}" \
|
||||
--notes "Automated snapshot build (${VERSION}) from commit ${{ github.sha }}." \
|
||||
--prerelease \
|
||||
artifacts/komari-*
|
||||
|
||||
gh workflow run generate-release-notes.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref main \
|
||||
-f tag="${VERSION}"
|
||||
|
||||
gh release list \
|
||||
--repo "${{ github.repository }}" \
|
||||
--limit 1000 \
|
||||
--json tagName,isPrerelease \
|
||||
--jq '.[] | select(.isPrerelease and (.tagName | startswith("Snapshot-"))) | .tagName' |
|
||||
while IFS= read -r tag; do
|
||||
if [ -z "${tag}" ] || [ "${tag}" = "${VERSION}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Deleting old snapshot release ${tag}..."
|
||||
gh release delete "${tag}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
done
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
needs: [prepare, build-and-attach-binaries, publish-snapshot-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot Docker publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=snapshot
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -7,12 +7,14 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
__debug*
|
||||
|
||||
# local development data
|
||||
komari.db
|
||||
/data
|
||||
/utils/geoip/data
|
||||
.vscode/
|
||||
/backup
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -29,3 +31,7 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
.codex
|
||||
+4
-8
@@ -6,20 +6,16 @@ WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
|
||||
ENV GIN_MODE=release
|
||||
ENV KOMARI_DB_TYPE=sqlite
|
||||
ENV KOMARI_DB_FILE=/app/data/komari.db
|
||||
ENV KOMARI_DB_HOST=localhost
|
||||
ENV KOMARI_DB_PORT=3306
|
||||
ENV KOMARI_DB_USER=root
|
||||
ENV KOMARI_DB_PASS=
|
||||
ENV KOMARI_DB_NAME=komari
|
||||
ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
CMD ["/app/komari", "server"]
|
||||
CMD ["/app/komari", "server"]
|
||||
@@ -0,0 +1,27 @@
|
||||
CoreOS Project
|
||||
Copyright 2014 CoreOS, Inc
|
||||
|
||||
This product includes software developed at CoreOS, Inc.
|
||||
(http://www.coreos.com/).
|
||||
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
|
||||
Copyright 2014 gRPC authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,92 +1,47 @@
|
||||
|
||||
# Komari
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||

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

|
||||
|
||||
## 特性
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
[简体中文](./README_zh-cn.md)
|
||||
|
||||
## 快速开始
|
||||
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
|
||||
|
||||
### 依赖
|
||||
- Docker(快速部署)
|
||||
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
|
||||
> [!WARNING]
|
||||
> Komari is a self-hosted monitoring and control program intended only for systems you own or are authorized to administer. Do not weaponize Komari, deploy it without consent, or use it for unauthorized access, persistence, command execution, or other abusive activity. For context on real-world abuse risks, see Huntress' analysis: [Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse).
|
||||
> Users are solely responsible for how they deploy and operate Komari. The developers do not accept responsibility for unauthorized or abusive use, or for any resulting consequences.
|
||||
> On Windows, when remote control is enabled, the client displays a Windows notification at each user login to remind the user that Komari is remote control software.
|
||||
|
||||
### 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://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](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` 设置。
|
||||
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
|
||||
- **Self-hosted**: Complete control over data privacy, easy to deploy.
|
||||
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
|
||||
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
## One-click Deployment
|
||||
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
### 手工构建
|
||||
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`。
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
## 前端开发指南
|
||||
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
|
||||
- 1Panel App Store
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
|
||||
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
For Docker, binary, source-build, and update instructions, see the [installation guide](https://komari-document.pages.dev/en/install/quick-start).
|
||||
|
||||
## 鸣谢
|
||||
- 感谢我自己能这么闲
|
||||
- 广告位招租喵
|
||||
## Contributing
|
||||
|
||||
## Star History
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
## Acknowledgements
|
||||
|
||||
| Acknowledgement | Details |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <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) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| Open-source community | Everyone who has submitted pull requests or created themes. |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Komari
|
||||
|
||||

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

|
||||
|
||||
[简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
|
||||
|
||||
[文档/Documentation](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel 应用商店
|
||||
|
||||
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
|
||||
|
||||
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鸣谢
|
||||
|
||||
| 赞助商 | 描述 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <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) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| 开源社区 | 提交 PR、制作主题的各位开发者 |
|
||||
@@ -1,35 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Komari is a single user system
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
|
||||
// 将 session 和 用户 UUID 传递到后续处理器
|
||||
c.Set("session", session)
|
||||
c.Set("uuid", uuid)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/patrickmn/go-cache"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
Records = cache.New(1*time.Minute, 1*time.Minute)
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
RequesterIp string
|
||||
}
|
||||
|
||||
var TerminalSessionsMutex = &sync.Mutex{}
|
||||
var TerminalSessions = make(map[string]*TerminalSession)
|
||||
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute).Unix()
|
||||
var records []models.Record
|
||||
|
||||
// 遍历所有客户端记录
|
||||
for uuid, x := range Records.Items() {
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
reports, ok := x.Object.([]common.Report)
|
||||
if !ok {
|
||||
log.Printf("Invalid report type for UUID %s", uuid)
|
||||
continue
|
||||
}
|
||||
|
||||
// 过滤一分钟前的记录
|
||||
var filtered []common.Report
|
||||
for _, r := range reports {
|
||||
if r.UpdatedAt.Unix() >= lastMinute {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
Records.Set(uuid, filtered, cache.DefaultExpiration)
|
||||
|
||||
// 计算平均报告并添加到记录列表
|
||||
if len(filtered) > 0 {
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
|
||||
records = append(records, r)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入数据库前去重(client与time共同构成唯一键)
|
||||
if len(records) > 0 {
|
||||
unique := make(map[string]models.Record)
|
||||
for _, rec := range records {
|
||||
key := rec.Client + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
|
||||
unique[key] = rec
|
||||
}
|
||||
var deduped []models.Record
|
||||
for _, rec := range unique {
|
||||
deduped = append(deduped, rec)
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
|
||||
log.Printf("Failed to save records to database: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Respond sends a standardized JSON response.
|
||||
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// RespondSuccess sends a success response with data.
|
||||
func RespondSuccess(c *gin.Context, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", "", data)
|
||||
}
|
||||
|
||||
// RespondSuccessMessage sends a success response with message and data.
|
||||
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", message, data)
|
||||
}
|
||||
|
||||
// RespondError sends an error response with message.
|
||||
func RespondError(c *gin.Context, httpStatus int, message string) {
|
||||
Respond(c, httpStatus, "error", message, nil)
|
||||
}
|
||||
func GetVersion(c *gin.Context) {
|
||||
RespondSuccess(c, gin.H{
|
||||
"version": utils.CurrentVersion,
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
|
||||
func TokenAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var token string
|
||||
|
||||
// Step 1: Check query parameter for token
|
||||
token = c.Query("token")
|
||||
|
||||
// Step 2: If no token in query, check request body for non-GET requests
|
||||
if token == "" && c.Request.Method != http.MethodGet {
|
||||
// Read the body
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the body for downstream handlers
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Try to extract token from JSON body
|
||||
var bodyMap map[string]interface{}
|
||||
if len(bodyBytes) > 0 {
|
||||
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
|
||||
if tokenVal, exists := bodyMap["token"]; exists {
|
||||
if str, ok := tokenVal.(string); ok && str != "" {
|
||||
token = str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := checkTokenExists(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func checkTokenExists(token string) (bool, error) {
|
||||
_, err := clients.GetClientUUIDByToken(token)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
)
|
||||
|
||||
func AddClient(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||
uuid, token, err := clients.CreateClient()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
|
||||
return
|
||||
}
|
||||
uuid, token, err := clients.CreateClientWithName(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
|
||||
}
|
||||
|
||||
func EditClient(c *gin.Context) {
|
||||
var req = make(map[string]interface{})
|
||||
uuid := c.Param("uuid")
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
|
||||
return
|
||||
}
|
||||
req["uuid"] = uuid
|
||||
err := clients.SaveClient(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func RemoveClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
err := clients.DeleteClient(uuid)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to delete client" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func ClearRecord(c *gin.Context) {
|
||||
if err := records.DeleteAll(); err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Failed to delete Record" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func GetClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func ListClients(c *gin.Context) {
|
||||
cls, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cls)
|
||||
}
|
||||
|
||||
func GetClientToken(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := clients.GetClientTokenByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func OrderWeight(c *gin.Context) {
|
||||
var req = make(map[string]int)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
for uuid, weight := range req {
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// GetClipboard retrieves a clipboard entry by ID
|
||||
func GetClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
cb, err := clipboardDB.GetClipboardByID(id)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cb)
|
||||
}
|
||||
|
||||
// ListClipboard lists all clipboard entries
|
||||
func ListClipboard(c *gin.Context) {
|
||||
list, err := clipboardDB.ListClipboard()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, list)
|
||||
}
|
||||
|
||||
// CreateClipboard creates a new clipboard entry
|
||||
func CreateClipboard(c *gin.Context) {
|
||||
var req models.Clipboard
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.CreateClipboard(&req); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
|
||||
api.RespondSuccess(c, req)
|
||||
}
|
||||
|
||||
// UpdateClipboard updates an existing clipboard entry
|
||||
func UpdateClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&fields); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// DeleteClipboard deletes a clipboard entry
|
||||
func DeleteClipboard(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboard(id); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// BatchDeleteClipboard deletes multiple clipboard entries
|
||||
func BatchDeleteClipboard(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
userUUID, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
|
||||
func DownloadBackup(c *gin.Context) {
|
||||
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
|
||||
c.Writer.Header().Set("Content-Type", "application/zip")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
|
||||
|
||||
zipWriter := zip.NewWriter(c.Writer)
|
||||
defer zipWriter.Close()
|
||||
|
||||
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取在 zip 包内的相对路径
|
||||
relPath, err := filepath.Rel("./data", filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跳过根目录本身,只打包内容
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// zip 内路径统一用正斜杠
|
||||
zipPath := filepath.ToSlash(relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
// 在 zip 中创建目录项
|
||||
_, err = zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath + "/",
|
||||
Method: zip.Deflate, // zip.Store 不压缩
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// 普通文件,写入 zip
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath,
|
||||
Method: zip.Deflate,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data folder: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 ./data 和数据库文件的绝对路径以进行可靠的比较
|
||||
dataAbsPath, err := filepath.Abs("./data")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error getting absolute path for data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
dbFilePath := flags.DatabaseFile
|
||||
dbAbsPath, err := filepath.Abs(dbFilePath)
|
||||
if err != nil {
|
||||
// 如果无法解析数据库路径,可能是一个配置问题,但我们记录并跳过,避免备份失败
|
||||
log.Printf("Could not determine absolute path for database file '%s', skipping explicit addition. Error: %v\n", dbFilePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
|
||||
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
dbInfo, err := os.Stat(dbFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
|
||||
return // 数据库不存在是正常情况,直接返回
|
||||
}
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !dbInfo.IsDir() {
|
||||
file, err := os.Open(dbFilePath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: dbFileName,
|
||||
Method: zip.Deflate,
|
||||
Modified: dbInfo.ModTime(),
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating zip entry for database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing database file to zip: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
|
||||
}
|
||||
|
||||
// zipWriter.Close() 由 defer 自动调用
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
// 接受数据类型:
|
||||
// - command: string
|
||||
// - clients: []string (客户端 UUID 列表)
|
||||
func Exec(c *gin.Context) {
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for uuid := range ws.GetConnectedClients() {
|
||||
if contain(req.Clients, uuid) {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
api.RespondError(c, 400, "No clients connected")
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, uuid := range onlineClients {
|
||||
var send struct {
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command"`
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
send.Message = "exec"
|
||||
send.Command = req.Command
|
||||
send.TaskId = taskId
|
||||
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.GetConnectedClients()[uuid]
|
||||
if client != nil {
|
||||
client.WriteMessage(websocket.TextMessage, payload)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client connection is null: "+uuid)
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
})
|
||||
}
|
||||
func contain(clients []string, uuid string) bool {
|
||||
for _, client := range clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetLogs(c *gin.Context) {
|
||||
limit := c.Query("limit")
|
||||
if limit == "" {
|
||||
limit = "100" // Default to 100 logs if not specified
|
||||
}
|
||||
page := c.Query("page")
|
||||
if page == "" {
|
||||
page = "1" // Default to page 1 if not specified
|
||||
}
|
||||
// Convert limit and page to integers
|
||||
// If conversion fails, return an error
|
||||
limitInt, err := strconv.Atoi(limit)
|
||||
if err != nil || limitInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid limit: "+limit)
|
||||
return
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil || pageInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid page: "+page)
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var logs []models.Log
|
||||
// 添加分页:计算偏移量并限制数量
|
||||
offset := (pageInt - 1) * limitInt
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/notification"
|
||||
)
|
||||
|
||||
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
|
||||
func AddLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Metric string `json:"metric" binding:"required"`
|
||||
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
|
||||
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Interval > 4*60 || req.Interval <= 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
|
||||
return
|
||||
}
|
||||
if req.Ratio <= 0 || req.Ratio > 1 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint
|
||||
func DeleteLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
ID []uint `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.DeleteLoadNotification(req.ID); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint, updates map[string]interface{}
|
||||
func EditLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.EditLoadNotification(req.Notifications); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, notification := range req.Notifications {
|
||||
// notification.DeleteLoadNotification([]uint{notification.Id})
|
||||
// }
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications(c *gin.Context) {
|
||||
notifications, err := notification.GetAllLoadNotifications()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// POST body : []uuid
|
||||
func EnableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// POST body : []uuid
|
||||
func DisableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func EditOfflineNotification(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
if err := c.ShouldBindJSON(¬ifications); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
api.RespondError(c, 400, "At least one notification is required")
|
||||
return
|
||||
}
|
||||
for _, noti := range notifications {
|
||||
if noti.Client == "" {
|
||||
api.RespondError(c, 400, "Client UUID cannot be empty")
|
||||
return
|
||||
}
|
||||
if noti.GracePeriod <= 0 {
|
||||
api.RespondError(c, 400, "GracePeriod must be a positive integer")
|
||||
return
|
||||
}
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
|
||||
}).
|
||||
Select("*").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func ListOfflineNotifications(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(¬ifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
// POST body: clients []string, target, task_type string, interval int
|
||||
func AddPingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Target string `json:"target" binding:"required"`
|
||||
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
|
||||
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint
|
||||
func DeletePingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
ID []uint `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.DeletePingTask(req.ID); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint, updates map[string]interface{}
|
||||
func EditPingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Tasks []*models.PingTask `json:"tasks" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.EditPingTask(req.Tasks); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, task := range req.Tasks {
|
||||
// tasks.DeletePingRecords([]uint{task.Id})
|
||||
// }
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllPingTasks(c *gin.Context) {
|
||||
tasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, tasks)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSessions(c *gin.Context) {
|
||||
|
||||
ss, err := accounts.GetAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
current, _ := c.Cookie("session_token")
|
||||
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
|
||||
}
|
||||
|
||||
func DeleteSession(c *gin.Context) {
|
||||
var req struct {
|
||||
Session string `json:"session" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := accounts.DeleteSession(req.Session)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteAllSession(c *gin.Context) {
|
||||
|
||||
err := accounts.DeleteAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSettings 获取自定义配置
|
||||
func GetSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
//override
|
||||
cst = models.Config{Sitename: "Komari"}
|
||||
cst.ID = 1
|
||||
config.Save(cst)
|
||||
api.RespondSuccess(c, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
|
||||
// EditSettings 更新自定义配置
|
||||
func EditSettings(c *gin.Context) {
|
||||
cfg := make(map[string]interface{})
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
ignoredKeys := []string{"id", "updated_at"}
|
||||
if contains(ignoredKeys, key) {
|
||||
continue
|
||||
}
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
message = message[:len(message)-2]
|
||||
}
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, v := range slice {
|
||||
if v == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ClearAllRecords(c *gin.Context) {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetTasks(c *gin.Context) {
|
||||
dbTasks, err := tasks.GetAllTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
var responseTasks []gin.H
|
||||
for _, t := range dbTasks {
|
||||
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
responseTasks = append(responseTasks, gin.H{
|
||||
"task_id": t.TaskId,
|
||||
"clients": t.Clients,
|
||||
"command": t.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, responseTasks)
|
||||
}
|
||||
|
||||
func GetTaskById(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
task, err := tasks.GetTaskByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
api.RespondError(c, 404, "Task not found")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": task.TaskId,
|
||||
"clients": task.Clients,
|
||||
"command": task.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTasksByClientId(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
tasks, err := tasks.GetTasksByClientId(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, tasks)
|
||||
}
|
||||
|
||||
func GetSpecificTaskResult(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
clientId := c.Param("uuid")
|
||||
if taskId == "" || clientId == "" {
|
||||
api.RespondError(c, 400, "Task ID and Client ID are required")
|
||||
return
|
||||
}
|
||||
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
api.RespondError(c, 404, "No result found for this task and client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, result)
|
||||
}
|
||||
|
||||
// Param: task_id
|
||||
func GetTaskResultsByTaskId(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No results found for this task")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
|
||||
func GetAllTaskResultByUUID(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetAllTasksResultByUUID(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
func TestSendMessage(c *gin.Context) {
|
||||
message := "Komari test message."
|
||||
err := messageSender.SendTextMessage(message, "Komari Test Notification")
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to send message: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func TestGeoIp(c *gin.Context) {
|
||||
ip := c.Query("ip")
|
||||
if ip == "" {
|
||||
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
|
||||
ip = cfIP
|
||||
} else {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, GeoIpRecord)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
)
|
||||
|
||||
func UploadFavicon(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteFavicon(c *gin.Context) {
|
||||
if err := os.Remove("./data/favicon.ico"); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "Favicon not found")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.UpdateDatabase(); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
)
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Name *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
SsoType *string `json:"sso_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Password == nil && req.Name == nil {
|
||||
api.RespondError(c, 400, "At least one field (username or password) must be provided")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && len(*req.Name) < 3 {
|
||||
api.RespondError(c, 400, "Username must be at least 3 characters long")
|
||||
return
|
||||
}
|
||||
if req.Password != nil && len(*req.Password) < 6 {
|
||||
api.RespondError(c, 400, "Password must be at least 6 characters long")
|
||||
return
|
||||
}
|
||||
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update user: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
|
||||
}
|
||||
@@ -1,193 +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/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils/notification"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
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.GetLatestReport()[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
|
||||
unsafeConn, 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
|
||||
}
|
||||
conn := ws.NewSafeConn(unsafeConn)
|
||||
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.GetConnectedClients()[uuid]; exists {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
|
||||
return
|
||||
}
|
||||
ws.SetConnectedClients(uuid, conn)
|
||||
go notification.OnlineNotification(uuid)
|
||||
defer func() {
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
notification.OfflineNotification(uuid)
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
type MessageType struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
var msgType MessageType
|
||||
err = json.Unmarshal(message, &msgType)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
|
||||
continue
|
||||
}
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
continue
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
|
||||
continue
|
||||
}
|
||||
ws.SetLatestReport(uuid, &report)
|
||||
case "ping_result":
|
||||
var reqBody struct {
|
||||
PingTaskID uint `json:"task_id"`
|
||||
PingResult int `json:"value"`
|
||||
PingType string `json:"ping_type"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
err = json.Unmarshal(message, &reqBody)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
|
||||
continue
|
||||
}
|
||||
pingResult := models.PingRecord{
|
||||
Client: uuid,
|
||||
TaskId: reqBody.PingTaskID,
|
||||
Value: reqBody.PingResult,
|
||||
Time: models.FromTime(reqBody.FinishedAt),
|
||||
}
|
||||
tasks.SavePingRecord(pingResult)
|
||||
default:
|
||||
log.Printf("Unknown message type: %s", msgType.Type)
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func SaveClientReport(uuid string, report common.Report) error {
|
||||
reports, _ := api.Records.Get(uuid)
|
||||
if reports == nil {
|
||||
reports = []common.Report{}
|
||||
}
|
||||
if report.CPU.Usage < 0.01 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
reports = append(reports.([]common.Report), report)
|
||||
api.Records.Set(uuid, reports, cache.DefaultExpiration)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func TaskResult(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
clientId, _ := clients.GetClientUUIDByToken(token)
|
||||
if clientId == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TaskId string `json:"task_id" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
FinishedAt time.Time `json:"finished_at" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UploadBasicInfo(c *gin.Context) {
|
||||
var cbi = map[string]interface{}{}
|
||||
if err := c.ShouldBindJSON(&cbi); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if uuid == "" || err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.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,15 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
records, _ := Records.Get(uuid)
|
||||
RespondSuccess(c, records)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFa string `json:"2fa_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := config.Get()
|
||||
if conf.DisablePasswordLogin {
|
||||
RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
}
|
||||
func Logout(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
accounts.DeleteSession(session)
|
||||
c.SetCookie("session_token", "", -1, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMe(c *gin.Context) {
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
user, err := accounts.GetUserByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMe(t *testing.T) {
|
||||
// 设置测试模式
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 创建测试用户
|
||||
user, err := accounts.CreateAccount("testuser", "password")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
uuid := user.UUID
|
||||
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionToken string
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "有效的会话",
|
||||
sessionToken: sessionToken,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "testuser",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的会话",
|
||||
sessionToken: "invalid_token",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无会话",
|
||||
sessionToken: "",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建测试路由
|
||||
router := gin.New()
|
||||
router.GET("/me", GetMe)
|
||||
|
||||
// 创建测试请求
|
||||
req, _ := http.NewRequest("GET", "/me", nil)
|
||||
if tt.sessionToken != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: tt.sessionToken,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建响应记录器
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// 执行请求
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 断言状态码
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
// 解析响应体
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 断言响应体
|
||||
assert.Equal(t, tt.expectedBody, response)
|
||||
})
|
||||
}
|
||||
|
||||
// 清除测试数据
|
||||
accounts.DeleteAccountByUsername("testuser")
|
||||
accounts.DeleteAllSessions()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
)
|
||||
|
||||
func GetNodesInformation(c *gin.Context) {
|
||||
clientList, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
count := len(clientList)
|
||||
// 公开信息不展示IP地址,私有备注不展示
|
||||
for i := 0; i < count; i++ {
|
||||
clientList[i].IPv4 = ""
|
||||
clientList[i].IPv6 = ""
|
||||
clientList[i].Remark = ""
|
||||
clientList[i].Version = ""
|
||||
clientList[i].Token = ""
|
||||
}
|
||||
|
||||
RespondSuccess(c, clientList)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
if !cfg.OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state := oauth.GetOAuthUrl()
|
||||
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
c.Redirect(302, authURL)
|
||||
}
|
||||
|
||||
// /api/oauth_callback
|
||||
func OAuthCallback(c *gin.Context) {
|
||||
// 验证state防止CSRF攻击
|
||||
state, _ := c.Cookie("oauth_state")
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
|
||||
if state == "" || state != c.Query("state") {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取code
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
|
||||
return
|
||||
}
|
||||
|
||||
githubUser, err := oauth.OAuthCallback(code, state)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用GitHub ID作为SSO ID
|
||||
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
|
||||
|
||||
// 如果cookie中有binding_external_account,说明是绑定外部账号
|
||||
// 否则是登录
|
||||
uuid, _ := c.Cookie("binding_external_account")
|
||||
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
|
||||
if uuid != "" {
|
||||
// 绑定外部账号
|
||||
session, _ := c.Cookie("session_token")
|
||||
user, err := accounts.GetUserBySession(session)
|
||||
if err != nil || user.UUID != uuid {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
|
||||
return
|
||||
}
|
||||
err = accounts.BindingExternalAccount(user.UUID, sso_id)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
|
||||
return
|
||||
}
|
||||
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
|
||||
c.Redirect(302, "/manage")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取用户
|
||||
user, err := accounts.GetUserBySSO(sso_id)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "error",
|
||||
"message": "please log in and bind your GitHub account first.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置cookie并返回
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
c.Redirect(302, "/manage")
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
if err != nil {
|
||||
RespondError(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Return public settings including CORS
|
||||
RespondSuccess(c, gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
"description": cst.Description,
|
||||
"custom_head": cst.CustomHead,
|
||||
"custom_body": cst.CustomBody,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
"record_enabled": cst.RecordEnabled,
|
||||
"record_preserve_time": cst.RecordPreserveTime,
|
||||
"ping_record_preserve_time": cst.PingRecordPreserveTime,
|
||||
})
|
||||
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
records "github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetRecordsByUUID(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
hours := c.Query("hours")
|
||||
if uuid == "" {
|
||||
api.RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
if hours == "" {
|
||||
hours = "4"
|
||||
}
|
||||
|
||||
hoursInt, err := strconv.Atoi(hours)
|
||||
if err != nil {
|
||||
api.RespondError(c, 400, "Invalid hours parameter")
|
||||
return
|
||||
}
|
||||
|
||||
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"records": records,
|
||||
"count": len(records),
|
||||
})
|
||||
}
|
||||
|
||||
// GET query: uuid string, hours int
|
||||
func GetPingRecords(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
hours := c.Query("hours")
|
||||
|
||||
if uuid == "" {
|
||||
api.RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
if hours == "" {
|
||||
hours = "4"
|
||||
}
|
||||
|
||||
hoursInt, err := strconv.Atoi(hours)
|
||||
if err != nil {
|
||||
api.RespondError(c, 400, "Invalid hours parameter")
|
||||
return
|
||||
}
|
||||
|
||||
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"records": records,
|
||||
"count": len(records),
|
||||
}
|
||||
|
||||
if len(records) > 0 {
|
||||
// 获取当前属于该 uuid 的 pingTasks
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
taskIdSet := make(map[uint]struct{})
|
||||
for _, r := range records {
|
||||
taskIdSet[r.TaskId] = struct{}{}
|
||||
}
|
||||
|
||||
tasksList := make([]gin.H, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
// 只保留分配给该 uuid 的任务
|
||||
found := false
|
||||
for _, client := range t.Clients {
|
||||
if client == uuid {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// 只返回有记录的任务
|
||||
if _, ok := taskIdSet[t.Id]; ok {
|
||||
tasksList = append(tasksList, gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"interval": t.Interval,
|
||||
})
|
||||
}
|
||||
}
|
||||
response["tasks"] = tasksList
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, response)
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func RequestTerminal(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
_, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 建立ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 新建一个终端连接
|
||||
id := utils.GenerateRandomString(32)
|
||||
session := &TerminalSession{
|
||||
UserUUID: user_uuid.(string),
|
||||
UUID: uuid,
|
||||
Browser: conn,
|
||||
Agent: nil,
|
||||
RequesterIp: c.ClientIP(),
|
||||
}
|
||||
|
||||
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.GetConnectedClients()[uuid] == nil {
|
||||
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
|
||||
"message": "terminal",
|
||||
"request_id": id,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
|
||||
// 如果没有连接上,则关闭连接
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Agent == nil {
|
||||
if session.Browser != nil {
|
||||
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
|
||||
session.Browser.Close()
|
||||
}
|
||||
conn.Close()
|
||||
delete(TerminalSessions, id)
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
//logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
}
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
session, exists := TerminalSessions[id]
|
||||
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
logOperation.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
|
||||
established_time := time.Now()
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
messageType, data, err := session.Browser.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if messageType == websocket.TextMessage {
|
||||
if session.Agent != nil && string(data[0:1]) == "{" {
|
||||
err = session.Agent.WriteMessage(websocket.TextMessage, data)
|
||||
} else if session.Agent != nil {
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
} else if session.Agent != nil {
|
||||
// 二进制消息,原样传递
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
_, data, err := session.Agent.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
if session.Browser != nil {
|
||||
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待错误或主动关闭
|
||||
<-errChan
|
||||
// 关闭连接
|
||||
if session.Agent != nil {
|
||||
session.Agent.Close()
|
||||
}
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
disconnect_time := time.Now()
|
||||
logOperation.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
}
|
||||
+28
-6
@@ -1,14 +1,36 @@
|
||||
package flags
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DatabaseTypeSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite, mysql
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // MySQL/其他数据库主机地址
|
||||
DatabasePort string // MySQL/其他数据库端口
|
||||
DatabaseUser string // MySQL/其他数据库用户名
|
||||
DatabasePass string // MySQL/其他数据库密码
|
||||
DatabaseName string // MySQL/其他数据库名称
|
||||
|
||||
Listen string
|
||||
)
|
||||
|
||||
func NormalizeDatabaseType(databaseType string) string {
|
||||
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
|
||||
if databaseType == "" {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
return databaseType
|
||||
}
|
||||
|
||||
func ApplyDatabaseTypeNormalization() string {
|
||||
DatabaseType = NormalizeDatabaseType(DatabaseType)
|
||||
return DatabaseType
|
||||
}
|
||||
|
||||
func IsSQLite() bool {
|
||||
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
|
||||
}
|
||||
|
||||
func SupportedDatabaseTypes() string {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package flags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDatabaseType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": DatabaseTypeSQLite,
|
||||
"sqlite": DatabaseTypeSQLite,
|
||||
" SQLite ": DatabaseTypeSQLite,
|
||||
"SQLITE": DatabaseTypeSQLite,
|
||||
"postgres": "postgres",
|
||||
" postgres": "postgres",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeDatabaseType(input); got != want {
|
||||
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.Config{}).Where("id = ?", 1).
|
||||
Update("disable_password_login", false).Error
|
||||
})
|
||||
if err != nil {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+2
-19
@@ -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")
|
||||
}
|
||||
+86
-308
@@ -1,276 +1,22 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
"github.com/komari-monitor/komari/api/admin/clipboard"
|
||||
log_api "github.com/komari-monitor/komari/api/admin/log"
|
||||
"github.com/komari-monitor/komari/api/admin/notification"
|
||||
"github.com/komari-monitor/komari/api/admin/test"
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/record"
|
||||
appserver "github.com/komari-monitor/komari/internal/server"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
u_notification "github.com/komari-monitor/komari/utils/notification"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
DynamicCorsEnabled bool = false
|
||||
)
|
||||
|
||||
var ServerCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the server",
|
||||
Long: `Start the server`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// #region 初始化
|
||||
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
|
||||
log.Fatalf("Failed to create data directory: %v", err)
|
||||
}
|
||||
InitDatabase()
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
|
||||
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
|
||||
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to run cloudflared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 动态 CORS 中间件
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "43200") // 12 hours
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
// #region 公开路由
|
||||
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)
|
||||
|
||||
r.GET("/api/records/load", record.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", record.GetPingRecords)
|
||||
// #region Agent
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", client.EstablishConnection)
|
||||
tokenAuthrized.POST("/task/result", client.TaskResult)
|
||||
}
|
||||
// #region 管理员
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
|
||||
// test
|
||||
testGroup := adminAuthrized.Group("/test")
|
||||
{
|
||||
testGroup.GET("/geoip", test.TestGeoIp)
|
||||
testGroup.POST("/sendMessage", test.TestSendMessage)
|
||||
}
|
||||
// update
|
||||
updateGroup := adminAuthrized.Group("/update")
|
||||
{
|
||||
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
|
||||
updateGroup.POST("/user", update.UpdateUser)
|
||||
updateGroup.PUT("/favicon", update.UploadFavicon)
|
||||
updateGroup.POST("/favicon", update.DeleteFavicon)
|
||||
}
|
||||
// tasks
|
||||
taskGroup := adminAuthrized.Group("/task")
|
||||
{
|
||||
taskGroup.GET("/all", admin.GetTasks)
|
||||
taskGroup.POST("/exec", admin.Exec)
|
||||
taskGroup.GET("/:task_id", admin.GetTaskById)
|
||||
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
|
||||
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
|
||||
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
|
||||
}
|
||||
// settings
|
||||
adminAuthrized.GET("/settings", admin.GetSettings)
|
||||
adminAuthrized.POST("/settings", admin.EditSettings)
|
||||
// clients
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
{
|
||||
clientGroup.POST("/add", admin.AddClient)
|
||||
clientGroup.GET("/list", admin.ListClients)
|
||||
clientGroup.GET("/:uuid", admin.GetClient)
|
||||
clientGroup.POST("/:uuid/edit", admin.EditClient)
|
||||
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
|
||||
clientGroup.GET("/:uuid/token", admin.GetClientToken)
|
||||
clientGroup.POST("/order", admin.OrderWeight)
|
||||
// client terminal
|
||||
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
recordGroup.POST("/clear/all", admin.ClearAllRecords)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
|
||||
// clipboard
|
||||
clipboardGroup := adminAuthrized.Group("/clipboard")
|
||||
{
|
||||
clipboardGroup.GET("/:id", clipboard.GetClipboard)
|
||||
clipboardGroup.GET("", clipboard.ListClipboard)
|
||||
clipboardGroup.POST("", clipboard.CreateClipboard)
|
||||
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
|
||||
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
|
||||
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
|
||||
}
|
||||
|
||||
notificationGroup := adminAuthrized.Group("/notification")
|
||||
{
|
||||
// offline notifications
|
||||
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
loadAlertGroup := notificationGroup.Group("/load")
|
||||
{
|
||||
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
|
||||
loadAlertGroup.POST("/add", notification.AddLoadNotification)
|
||||
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
|
||||
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
|
||||
}
|
||||
}
|
||||
|
||||
pingTaskGroup := adminAuthrized.Group("/ping")
|
||||
{
|
||||
pingTaskGroup.GET("/", admin.GetAllPingTasks)
|
||||
pingTaskGroup.POST("/add", admin.AddPingTask)
|
||||
pingTaskGroup.POST("/delete", admin.DeletePingTask)
|
||||
pingTaskGroup.POST("/edit", admin.EditPingTask)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
// #region 静态文件服务
|
||||
public.UpdateIndex(conf)
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
public.UpdateIndex(event.New)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
OnFatal(err)
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
OnShutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
RunServer()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -281,64 +27,96 @@ func init() {
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
func InitDatabase() {
|
||||
// // 打印数据库类型和连接信息
|
||||
// if flags.DatabaseType == "mysql" {
|
||||
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
|
||||
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
|
||||
// } else {
|
||||
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
|
||||
// }
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
// RunServer 按显式的生命周期阶段启动服务端。
|
||||
//
|
||||
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
|
||||
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
|
||||
func RunServer() {
|
||||
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
|
||||
}
|
||||
|
||||
installRequired, err := app.InstallRequired()
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", err)
|
||||
}
|
||||
if installRequired {
|
||||
completed, err := app.RunInstallGuide()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
}
|
||||
}
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
tasks.ReloadPingSchedule()
|
||||
d_notification.ReloadLoadNotificationSchedule()
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
cfg, _ := config.Get()
|
||||
go u_notification.CheckExpireScheduledWork()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
logOperation.RemoveOldLogs()
|
||||
case <-minute.C:
|
||||
api.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
requirement, err := app.DatabaseMigrationRequired()
|
||||
if err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "database-migration-detection-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !requirement.Required() {
|
||||
break
|
||||
}
|
||||
completed, err := app.RunDatabaseMigration(requirement)
|
||||
if err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-database-migration", err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
|
||||
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
|
||||
if err := app.ConnectMetricStoreWithRetry(); err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
logOperation.Log("", "", "server is shutting down", "info")
|
||||
cloudflared.Kill()
|
||||
}
|
||||
// 其余初始化阶段:任一步失败都不应继续对外服务。
|
||||
type stage struct {
|
||||
name string
|
||||
fn func() error
|
||||
}
|
||||
stages := []stage{
|
||||
{"init-stores", app.InitStores},
|
||||
{"init-providers", app.InitProviders},
|
||||
{"start-background", app.StartBackground},
|
||||
{"build-router", app.BuildRouter},
|
||||
}
|
||||
for _, s := range stages {
|
||||
if err := s.fn(); err != nil {
|
||||
// 已登记的资源尽力回收,再退出。
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Kill()
|
||||
if err := app.Run(); err != nil {
|
||||
if errors.Is(err, appserver.ErrRestartRequested) {
|
||||
logger.Infof("server", "Service stopped for restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Fatalf("server", "server exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
CPU bool `json:"cpu" gorm:"default:true"`
|
||||
GPU bool `json:"gpu" gorm:"default:true"`
|
||||
RAM bool `json:"ram" gorm:"default:true"`
|
||||
SWAP bool `json:"swap" gorm:"default:true"`
|
||||
LOAD bool `json:"load" gorm:"default:true"`
|
||||
UPTIME bool `json:"uptime" gorm:"default:true"`
|
||||
TEMP bool `json:"temp" gorm:"default:true"`
|
||||
OS bool `json:"os" gorm:"default:true"`
|
||||
DISK bool `json:"disk" gorm:"default:true"`
|
||||
NET bool `json:"net" gorm:"default:true"`
|
||||
PROCESS bool `json:"process" gorm:"default:true"`
|
||||
Connections bool `json:"connections" gorm:"default:true"`
|
||||
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClientInfo stores static client information
|
||||
// Deprecated: Use models.Client instead.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type IPAddress struct {
|
||||
Ipv4 string `json:"ipv4"`
|
||||
Ipv6 string `json:"ipv6"`
|
||||
}
|
||||
type Report struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
CPU CPUReport `json:"cpu"`
|
||||
Ram RamReport `json:"ram"`
|
||||
Swap RamReport `json:"swap"`
|
||||
Load LoadReport `json:"load"`
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
Method string `json:"method,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type RamReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type LoadReport struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type DiskReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type NetworkReport struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
TotalUp int64 `json:"totalUp"`
|
||||
TotalDown int64 `json:"totalDown"`
|
||||
}
|
||||
|
||||
type ConnectionsReport struct {
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const constantSalt = "06Wm4Jv1Hkxx"
|
||||
@@ -56,7 +54,10 @@ func hashPasswd(passwd string) string {
|
||||
}
|
||||
|
||||
func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
|
||||
}
|
||||
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
user = models.User{
|
||||
UUID: uuid.New().String(),
|
||||
@@ -71,7 +72,10 @@ func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
}
|
||||
|
||||
func DeleteAccountByUsername(username string) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
|
||||
}
|
||||
|
||||
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
|
||||
err = db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,39 +83,6 @@ func DeleteAccountByUsername(username string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建默认管理员账户,使用环境变量 ADMIN_USERNAME 作为用户名,环境变量 ADMIN_PASSWORD 作为密码
|
||||
func CreateDefaultAdminAccount() (username, passwd string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
username = os.Getenv("ADMIN_USERNAME")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
passwd = os.Getenv("ADMIN_PASSWORD")
|
||||
if passwd == "" {
|
||||
passwd = utils.GeneratePassword()
|
||||
}
|
||||
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
SSOID: "",
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, passwd, nil
|
||||
}
|
||||
|
||||
func GetUserByUUID(uuid string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&user).Error
|
||||
@@ -171,7 +142,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
if sso_type != nil {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,11 +2,17 @@ package accounts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// GetAllSessions 获取所有会话
|
||||
@@ -27,12 +33,29 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
|
||||
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
LatestOnline: time.Now().UTC(),
|
||||
}
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
if LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now().UTC(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
@@ -50,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().After(sessionRecord.Expires.ToTime()) {
|
||||
if time.Now().UTC().After(sessionRecord.Expires) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -88,25 +111,20 @@ func DeleteAllSessions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateLatestOnline(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
|
||||
}
|
||||
|
||||
func UpdateLatestUserAgent(session, userAgent string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
|
||||
}
|
||||
func UpdateLatestIp(session, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
|
||||
}
|
||||
|
||||
func UpdateLatest(session, useragent, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
|
||||
"latest_online": time.Now(),
|
||||
"latest_online": time.Now().UTC(),
|
||||
"latest_user_agent": useragent,
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package logOperation
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"log"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
@@ -9,23 +9,28 @@ import (
|
||||
)
|
||||
|
||||
func Log(ip, uuid, message, msgType string) {
|
||||
now := time.Now()
|
||||
db := dbcore.GetDBInstance()
|
||||
logEntry := &models.Log{
|
||||
IP: ip,
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: models.FromTime(now),
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
db.Create(logEntry)
|
||||
if err := db.Create(logEntry).Error; err != nil {
|
||||
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
|
||||
}
|
||||
}
|
||||
|
||||
func EventLog(eventType, message string) {
|
||||
Log("", "", message, eventType)
|
||||
}
|
||||
|
||||
// Delete logs older than 30 days
|
||||
func RemoveOldLogs() {
|
||||
db := dbcore.GetDBInstance()
|
||||
threshold := time.Now().AddDate(0, 0, -30)
|
||||
threshold := time.Now().UTC().AddDate(0, 0, -30)
|
||||
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
|
||||
log.Println("Failed to remove old logs:", err)
|
||||
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
|
||||
}
|
||||
}
|
||||
+120
-146
@@ -1,29 +1,20 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
|
||||
func DeleteClientConfig(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
@@ -33,79 +24,6 @@ func DeleteClient(clientUuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
|
||||
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if cbi.Name != "" {
|
||||
updates["name"] = cbi.Name
|
||||
}
|
||||
if cbi.CpuName != "" {
|
||||
updates["cpu_name"] = cbi.CpuName
|
||||
}
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores != 0 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
updates["os"] = cbi.OS
|
||||
}
|
||||
if cbi.GpuName != "" {
|
||||
updates["gpu_name"] = cbi.GpuName
|
||||
}
|
||||
if cbi.IPv4 != "" {
|
||||
updates["ipv4"] = cbi.IPv4
|
||||
}
|
||||
if cbi.IPv6 != "" {
|
||||
updates["ipv6"] = cbi.IPv6
|
||||
}
|
||||
if cbi.Region != "" {
|
||||
updates["region"] = cbi.Region
|
||||
}
|
||||
if cbi.Remark != "" {
|
||||
updates["remark"] = cbi.Remark
|
||||
}
|
||||
updates["mem_total"] = cbi.MemTotal
|
||||
updates["swap_total"] = cbi.SwapTotal
|
||||
updates["disk_total"] = cbi.DiskTotal
|
||||
updates["version"] = cbi.Version
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
// 转换为更新Client表
|
||||
client := models.Client{
|
||||
UUID: cbi.UUID,
|
||||
}
|
||||
|
||||
err := db.Model(&client).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(map[string]interface{}{
|
||||
"uuid": cbi.UUID,
|
||||
"name": cbi.Name,
|
||||
"cpu_name": cbi.CpuName,
|
||||
"arch": cbi.Arch,
|
||||
"cpu_cores": cbi.CpuCores,
|
||||
"os": cbi.OS,
|
||||
"gpu_name": cbi.GpuName,
|
||||
"ipv4": cbi.IPv4,
|
||||
"ipv6": cbi.IPv6,
|
||||
"region": cbi.Region,
|
||||
"remark": cbi.Remark,
|
||||
"mem_total": cbi.MemTotal,
|
||||
"swap_total": cbi.SwapTotal,
|
||||
"disk_total": cbi.DiskTotal,
|
||||
"version": cbi.Version,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
@@ -118,52 +36,85 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新客户端设置
|
||||
func UpdateClientConfig(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Save(&config).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientName(clientUUID, clientName string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// UpdateClientByUUID 更新指定 UUID 的客户端配置
|
||||
|
||||
func UpdateClientByUUID(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int8:
|
||||
return float64(typed), true
|
||||
case int16:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case uint:
|
||||
return float64(typed), true
|
||||
case uint8:
|
||||
return float64(typed), true
|
||||
case uint16:
|
||||
return float64(typed), true
|
||||
case uint32:
|
||||
return float64(typed), true
|
||||
case uint64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
checkOptionalInt := func(name, key string, maxValue float64) error {
|
||||
value, exists := update[key]
|
||||
if !exists || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
numericValue, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s must be a valid number", name)
|
||||
}
|
||||
if numericValue < 0 || numericValue > maxValue {
|
||||
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
func EditClientToken(clientUUID, token string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verify(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,14 +131,17 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -202,14 +156,17 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -234,19 +191,6 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
|
||||
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
|
||||
}
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -278,7 +222,37 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, exists := updates["expired_at"]; exists {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
updates["expired_at"] = nil
|
||||
case time.Time:
|
||||
updates["expired_at"] = typed.UTC()
|
||||
case *time.Time:
|
||||
if typed == nil {
|
||||
updates["expired_at"] = nil
|
||||
} else {
|
||||
updates["expired_at"] = typed.UTC()
|
||||
}
|
||||
case string:
|
||||
stamp, err := time.Parse(time.RFC3339Nano, typed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
|
||||
}
|
||||
updates["expired_at"] = stamp.UTC()
|
||||
default:
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
|
||||
+75
-107
@@ -1,34 +1,14 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"math"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
// Report 表示客户端报告数据
|
||||
// SaveReport 保存报告数据
|
||||
func SaveReport(uuid string, data map[string]interface{}) (err error) {
|
||||
|
||||
report, err := ParseReport(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -39,98 +19,86 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(time.Now()),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
SwapTotal: report.Swap.Total,
|
||||
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
|
||||
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
|
||||
Disk: report.Disk.Used,
|
||||
DiskTotal: report.Disk.Total,
|
||||
NetIn: report.Network.Down,
|
||||
NetOut: report.Network.Up,
|
||||
NetTotalUp: report.Network.TotalUp,
|
||||
NetTotalDown: report.Network.TotalDown,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
|
||||
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
|
||||
}
|
||||
|
||||
checkFloat64 := func(name string, val float64) error {
|
||||
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
|
||||
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// [float64] 防止数据溢出
|
||||
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkInt64 := func(name string, val int64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, val)
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("Process must be non-negative: %d", report.Process)
|
||||
}
|
||||
if report.Connections.TCP < 0 {
|
||||
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
|
||||
}
|
||||
if report.Connections.UDP < 0 {
|
||||
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// getString 从 map 中获取字符串
|
||||
func getString(data map[string]interface{}, key string) string {
|
||||
if val, ok := data[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt 从 map 中获取整数
|
||||
func getInt(data map[string]interface{}, key string) int {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getInt64 从 map 中获取 int64
|
||||
func getInt64(data map[string]interface{}, key string) int64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int64(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFloat 从 map 中获取 float64
|
||||
func getFloat(data map[string]interface{}, key string) float64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
*/
|
||||
@@ -1,6 +1,8 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
@@ -8,6 +10,8 @@ import (
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
cb.CreatedAt = time.Now().UTC()
|
||||
cb.UpdatedAt = cb.CreatedAt
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
@@ -24,6 +28,8 @@ func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
delete(fields, "created_at")
|
||||
fields["updated_at"] = time.Now().UTC()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Get() (models.Config, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.Config
|
||||
if err := db.First(&config).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
config = models.Config{
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ip-api",
|
||||
EmailPort: 587,
|
||||
EmailUseSSL: true,
|
||||
EmailReceiver: "", // Default empty
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
log.Fatal("Failed to create default config:", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Save(cst models.Config) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = models.FromTime(time.Now())
|
||||
// Do not update CreatedAt
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("sitename",
|
||||
"description",
|
||||
"allow_cors",
|
||||
"geo_ip_enabled",
|
||||
"geo_ip_provider",
|
||||
"o_auth_client_id",
|
||||
"o_auth_client_secret",
|
||||
"o_auth_enabled",
|
||||
"custom_head",
|
||||
"email_enabled",
|
||||
"email_host",
|
||||
"email_port",
|
||||
"email_username",
|
||||
"email_password",
|
||||
"email_sender",
|
||||
"email_use_ssl",
|
||||
"email_receiver",
|
||||
"updated_at").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newConfig, _ := Get()
|
||||
publishEvent(oldConfig, newConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a configuration change event
|
||||
type ConfigEvent struct {
|
||||
Old models.Config
|
||||
New models.Config
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal models.Config) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
for _, sub := range subscribers {
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
+443
-135
@@ -1,169 +1,477 @@
|
||||
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"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"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"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
log.Printf("Failed to read ClientInfo table: %v", err)
|
||||
return
|
||||
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
|
||||
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
|
||||
// 规范化排除路径为绝对路径
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
|
||||
for _, info := range clientInfos {
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
|
||||
continue
|
||||
out, err := os.Create(dstZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDir)
|
||||
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新Client记录
|
||||
client.Name = info.Name
|
||||
client.CpuName = info.CpuName
|
||||
client.Virtualization = info.Virtualization
|
||||
client.Arch = info.Arch
|
||||
client.CpuCores = info.CpuCores
|
||||
client.OS = info.OS
|
||||
client.GpuName = info.GpuName
|
||||
client.IPv4 = info.IPv4
|
||||
client.IPv6 = info.IPv6
|
||||
client.Region = info.Region
|
||||
client.Remark = info.Remark
|
||||
client.PublicRemark = info.PublicRemark
|
||||
client.MemTotal = info.MemTotal
|
||||
client.SwapTotal = info.SwapTotal
|
||||
client.DiskTotal = info.DiskTotal
|
||||
client.Version = info.Version
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = models.FromTime(info.ExpiredAt)
|
||||
// Save updated Client record
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
log.Printf("Failed to update Client record: %v", err)
|
||||
continue
|
||||
// 排除 backup.zip 本身
|
||||
if _, ok := normExclude[path]; ok {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// 计算 zip 内相对路径
|
||||
rel, err := filepath.Rel(absSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 根目录跳过
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 替换为正斜杠
|
||||
zipName := filepath.ToSlash(rel)
|
||||
|
||||
// Backup and rename old table after migration
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
log.Printf("Failed to backup ClientInfo table: %v", err)
|
||||
return
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(zipName + "/")
|
||||
return err
|
||||
}
|
||||
// 普通文件
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(zipName)
|
||||
if err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, fh); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
fh.Close()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func MergeDatabase(db *gorm.DB) {
|
||||
if db.Migrator().HasTable("client_infos") {
|
||||
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
|
||||
mergeClientInfo(db)
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
db.Migrator().DropTable(&models.LoadNotification{})
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(absDir, e.Name())
|
||||
if _, ok := normExclude[full]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
|
||||
func unzipToDir(zipPath, dstDir string) error {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
absDst, _ := filepath.Abs(dstDir)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// 构造目标路径并做路径遍历保护
|
||||
cleanName := filepath.Clean(f.Name)
|
||||
targetPath := filepath.Join(absDst, cleanName)
|
||||
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
|
||||
return fmt.Errorf("illegal file path in zip: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
// 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
|
||||
|
||||
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
|
||||
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
|
||||
// 之前采集。
|
||||
var dbFileExistedAtStartup bool
|
||||
|
||||
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
|
||||
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
|
||||
func SetVersionID(id string) {
|
||||
versionID = id
|
||||
}
|
||||
|
||||
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
|
||||
func resolveDatabaseFile() string {
|
||||
dbFile := flags.DatabaseFile
|
||||
if dbFile == "" {
|
||||
dbFile = "./data/komari.db"
|
||||
}
|
||||
return dbFile
|
||||
}
|
||||
|
||||
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
|
||||
// ./data/backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
|
||||
//
|
||||
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
|
||||
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
|
||||
//
|
||||
// 触发规则:
|
||||
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
|
||||
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
|
||||
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
|
||||
// - 配置中版本与当前不同:版本升级,备份。
|
||||
// - 配置中版本与当前一致:无需备份。
|
||||
//
|
||||
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
|
||||
func backupOnVersionUpgrade() {
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
if versionRecorded && prevVersion == versionID {
|
||||
return
|
||||
}
|
||||
|
||||
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
|
||||
if !versionRecorded && !dbFileExistedAtStartup {
|
||||
writeVersionMarker()
|
||||
return
|
||||
}
|
||||
|
||||
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
|
||||
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
|
||||
// 避免备份出的库缺少仍留在 -wal 中的写入。
|
||||
if instance != nil {
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
|
||||
return
|
||||
}
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("upgrade-%s.zip", tsName))
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
|
||||
return
|
||||
}
|
||||
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
|
||||
|
||||
writeVersionMarker()
|
||||
}
|
||||
|
||||
// writeVersionMarker 将当前 versionID 写入配置库。
|
||||
func writeVersionMarker() {
|
||||
if err := config.Set(SystemVersionKey, versionID); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQLiteDSN(databaseFile string) string {
|
||||
if databaseFile == "" {
|
||||
databaseFile = "./data/komari.db"
|
||||
}
|
||||
|
||||
params := fmt.Sprintf("_busy_timeout=%d&_txlock=immediate", mainSQLiteBusyTimeout.Milliseconds())
|
||||
separator := "?"
|
||||
if strings.Contains(databaseFile, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(databaseFile, "file:") {
|
||||
return databaseFile + separator + params
|
||||
}
|
||||
|
||||
if databaseFile == ":memory:" {
|
||||
return "file::memory:?cache=shared&" + params
|
||||
}
|
||||
|
||||
return "file:" + filepath.ToSlash(databaseFile) + separator + params
|
||||
}
|
||||
|
||||
func mainSQLiteOptions() sqlitetune.Options {
|
||||
return sqlitetune.Options{
|
||||
BusyTimeout: mainSQLiteBusyTimeout,
|
||||
CacheSizeKB: mainSQLiteCacheSizeKB,
|
||||
MMapSizeBytes: 0,
|
||||
TempStoreMemory: false,
|
||||
CacheSpill: true,
|
||||
WALAutoCheckpoint: mainSQLiteWALAutoCheckpoint,
|
||||
JournalSizeLimitBytes: mainSQLiteJournalSizeLimit,
|
||||
Synchronous: sqlitetune.SynchronousNormal,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
|
||||
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
|
||||
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
|
||||
func Initialize() error {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
if utils.VersionHash == "unknown" {
|
||||
logConfig = &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.DatabaseType {
|
||||
case "sqlite", "":
|
||||
// SQLite 连接
|
||||
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
|
||||
case "mysql":
|
||||
// MySQL 连接
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
flags.DatabaseUser,
|
||||
flags.DatabasePass,
|
||||
flags.DatabaseHost,
|
||||
flags.DatabasePort,
|
||||
flags.DatabaseName)
|
||||
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
}
|
||||
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
}
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.PingRecord{},
|
||||
&models.PingTask{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create tables: %v", err)
|
||||
}
|
||||
err = instance.Table("records_long_term").AutoMigrate(
|
||||
&models.Record{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
initErr = doInitialize()
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetDBInstance 返回全局数据库实例。
|
||||
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
|
||||
// 需要错误处理的启动流程应优先调用 Initialize()。
|
||||
func GetDBInstance() *gorm.DB {
|
||||
if err := Initialize(); err != nil {
|
||||
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
// Close 关闭底层数据库连接,供关闭流程调用。
|
||||
func Close() error {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := instance.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func doInitialize() error {
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 将当前数据快照保存到 ./data/backup/,并保留已有归档。
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("pre-restore-%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除数据文件,但保留归档目录和待恢复的 backup.zip。
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}, backupDir: {}}); delErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
|
||||
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
|
||||
// gorm.Open 会创建空库。
|
||||
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
|
||||
dbFileExistedAtStartup = true
|
||||
}
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.NewGormLogger(),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// _txlock=immediate lets writes acquire their lock before reads can
|
||||
// turn into a lock-upgrade conflict. sqlitetune applies the remaining
|
||||
// per-connection PRAGMAs whenever database/sql opens a connection.
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
sqlDB, dbErr := sqlitetune.Open(dsn, mainSQLiteOptions())
|
||||
if dbErr != nil {
|
||||
return fmt.Errorf("open SQLite connection pool: %w", dbErr)
|
||||
}
|
||||
// SQLite has one writer. Keeping exactly one durable connection also
|
||||
// keeps connection-local cache and WAL limits stable for the main DB.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
sqlDB.SetConnMaxIdleTime(0)
|
||||
|
||||
instance, err = gorm.Open(sqlite.New(sqlite.Config{Conn: sqlDB}), logConfig)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
|
||||
}
|
||||
if err := instance.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
|
||||
logger.Errorf("dbcore", "Failed to checkpoint SQLite WAL at startup: %v", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
return fmt.Errorf("failed to run startup migrations: %w", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
|
||||
// 配置库就绪后、执行后续 AutoMigrate 之前:
|
||||
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
|
||||
backupOnVersionUpgrade()
|
||||
|
||||
// 自动迁移模型
|
||||
//
|
||||
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
|
||||
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
|
||||
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
|
||||
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
|
||||
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
|
||||
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
)
|
||||
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,29 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllMessageSenderConfigs() []models.MessageSenderProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.MessageSenderProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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,95 @@
|
||||
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) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
if store == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
return MaintenanceResult{
|
||||
Driver: store.Driver(),
|
||||
Action: store.MaintenanceAction(),
|
||||
BeforeSizeError: ErrStoreBusy,
|
||||
AfterSizeError: ErrStoreBusy,
|
||||
}, ErrStoreBusy
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
result := MaintenanceResult{
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
result.Before, result.BeforeSizeError = activeStore.StorageSize(ctx)
|
||||
maintenanceErr := activeStore.ReclaimSpace(ctx)
|
||||
result.After, result.AfterSizeError = activeStore.StorageSize(ctx)
|
||||
return result, maintenanceErr
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"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 TestRetryMetricWALCheckpointDefersBusyReader(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "checkpoint.db")
|
||||
s, err := metric.Open(ctx, metric.SQLite(path, metric.WithSQLiteWALAutoCheckpoint(1_000_000)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: "before", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reader, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reader.Close() })
|
||||
tx, err := reader.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var definitions int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM metric_definitions`).Scan(&definitions); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: "after", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previousPending := checkpointPending
|
||||
checkpointPending = true
|
||||
t.Cleanup(func() { checkpointPending = previousPending })
|
||||
retryMetricWALCheckpoint(ctx, s)
|
||||
if !checkpointPending {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal("busy reader unexpectedly cleared pending checkpoint")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retryMetricWALCheckpoint(ctx, s)
|
||||
if checkpointPending {
|
||||
t.Fatal("checkpoint remained pending after reader released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimSpaceReportsBusyStore(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)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
result, err := ReclaimSpace(context.Background())
|
||||
if !errors.Is(err, ErrStoreBusy) {
|
||||
t.Fatalf("reclaim error = %v, want %v", err, ErrStoreBusy)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("busy result lost store metadata: %#v", result)
|
||||
}
|
||||
if !errors.Is(result.BeforeSizeError, ErrStoreBusy) || !errors.Is(result.AfterSizeError, ErrStoreBusy) {
|
||||
t.Fatalf("busy result should mark both measurements unavailable: %#v", result)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,100 @@
|
||||
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,
|
||||
}
|
||||
|
||||
var obsoleteBuiltinMetricNames = []string{
|
||||
"memory.total",
|
||||
"swap.total",
|
||||
"temperature",
|
||||
"disk.total",
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,606 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
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 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 TestCreateMetricDefinitionsRemovesObsoleteMetrics(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)
|
||||
}
|
||||
if _, err := s.GetMetric(ctx, "memory.total"); !errors.Is(err, metric.ErrNotFound) {
|
||||
t.Fatalf("obsolete definition error = %v, want ErrNotFound", err)
|
||||
}
|
||||
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 obsolete points: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("obsolete points remain: %#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
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
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 TestCompactContinuesAfterOneMetricFails(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
|
||||
previousCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMu.Lock()
|
||||
store = previousStore
|
||||
compactAt = previousCompactAt
|
||||
storeMu.Unlock()
|
||||
})
|
||||
|
||||
if _, err := Compact(ctx, now); err == nil {
|
||||
t.Fatal("expected malformed metric to fail compaction")
|
||||
}
|
||||
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("healthy metric was blocked by another metric failure: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactKeepsRotatingCursorAfterFullCycle(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)
|
||||
}
|
||||
for _, name := range []string{"a.metric", "b.metric", "c.metric"} {
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge}); err != nil {
|
||||
t.Fatalf("upsert metric %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 1
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if _, err := Compact(ctx, time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
if compactAt != 1 {
|
||||
t.Fatalf("compact cursor = %d, want 1 after a complete rotated cycle", compactAt)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
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, v1.Report{
|
||||
UUID: rec.Client,
|
||||
UpdatedAt: ts,
|
||||
CPU: v1.CPUReport{Usage: float64(rec.Cpu)},
|
||||
Ram: v1.RamReport{Used: rec.Ram, Total: rec.RamTotal},
|
||||
Load: v1.LoadReport{Load1: float64(rec.Load)},
|
||||
Disk: v1.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
|
||||
Process: rec.Process,
|
||||
Connections: v1.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,149 @@
|
||||
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,
|
||||
}, 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"})
|
||||
}
|
||||
|
||||
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
|
||||
// 把其中的全部指标定义和持久化 rollup 搬运到当前目标 dst(例如 SQLite
|
||||
// metrics.db → MySQL/PostgreSQL)。
|
||||
//
|
||||
// 该过程幂等:dst 以 (series, resolution, labels, bucket) upsert 写入,中断后重启可安全重跑。
|
||||
func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, dst *metric.Store) error {
|
||||
prevCfg, err := configFromFingerprint(prevFingerprint, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse previous metrics target %q: %w", prevFingerprint, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
src, err := openSourceStore(ctx, prevCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open previous metrics store (%s): %w", prevFingerprint, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
logger.Infof("metricstore", "Migrating metrics from previous store %s to current target...", prevFingerprint)
|
||||
total, err := MigrateBetweenStores(ctx, src, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store-to-store metrics migration failed: %w", err)
|
||||
}
|
||||
logger.Infof("metricstore", "Store-to-store metrics migration completed (%d rollups)", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
|
||||
// - currentMetric:当前正在搬运的指标名。
|
||||
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
|
||||
// - totalMetrics:指标定义总数。
|
||||
// - addedPoints:本次新写入目标库的 rollup 行数(用于外部累计)。
|
||||
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
|
||||
|
||||
// MigrateBetweenStores 把 src metric store 的全部指标定义与持久化 rollup 搬运到 dst。
|
||||
//
|
||||
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
|
||||
// - 先在 dst 建立/更新全部指标定义;
|
||||
// - 再按指标流式读取源库 rollup 并分批写入 dst。
|
||||
//
|
||||
// rollup 在 dst 以 (series, resolution, labels, bucket) upsert 写入,因此整个
|
||||
// 过程可安全重试。返回搬运的 rollup 行数。
|
||||
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
|
||||
return migrateBetweenStores(ctx, src, dst, nil)
|
||||
}
|
||||
|
||||
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
|
||||
// 观察者 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,152 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
func TestMigrateBetweenStoresCopiesPersistedRollupsAfterReopen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
sourcePath := filepath.Join(t.TempDir(), "source.db")
|
||||
source, err := metric.Open(ctx, metric.SQLite(sourcePath,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open source: %v", err)
|
||||
}
|
||||
|
||||
definitions := []metric.Definition{
|
||||
{Name: "a.metric", Description: "migrated definition", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30, Metadata: map[string]string{"owner": "test"}},
|
||||
{Name: "z.empty", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 7, Metadata: map[string]string{}},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if err := source.UpsertMetric(ctx, definition); err != nil {
|
||||
t.Fatalf("upsert source metric %q: %v", definition.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
digest := metric.NewTDigest(policy.Compression)
|
||||
for _, value := range []float64{10, 20, 30} {
|
||||
digest.Add(value, 1)
|
||||
}
|
||||
sourceRollups := []metric.PersistedRollup{
|
||||
{
|
||||
MetricName: "a.metric", EntityID: "agent-1", Tags: map[string]string{"device": "cpu0"}, Labels: map[string]string{"zone": "ap"},
|
||||
Resolution: time.Minute, Bucket: base, Count: 3, Sum: 60, SumSq: 1400, Min: 10, Max: 30,
|
||||
FirstValue: 10, FirstTime: base.Add(time.Second), LastValue: 30, LastTime: base.Add(40 * time.Second),
|
||||
Digest: digest.Encode(), CreatedAt: base.Add(time.Hour),
|
||||
},
|
||||
{
|
||||
MetricName: "a.metric", EntityID: "agent-2", Tags: map[string]string{}, Labels: map[string]string{"zone": "eu"},
|
||||
Resolution: time.Hour, Bucket: base, Count: 12, Sum: 60, SumSq: 300, Min: 5, Max: 5,
|
||||
FirstValue: 5, FirstTime: base.Add(time.Minute), LastValue: 5, LastTime: base.Add(50 * time.Minute),
|
||||
CreatedAt: base.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
if err := source.ImportRollups(ctx, sourceRollups); err != nil {
|
||||
t.Fatalf("seed source rollups: %v", err)
|
||||
}
|
||||
if err := source.Close(); err != nil {
|
||||
t.Fatalf("close source: %v", err)
|
||||
}
|
||||
|
||||
source, err = metric.Open(ctx, metric.SQLite(sourcePath,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen source: %v", err)
|
||||
}
|
||||
defer source.Close()
|
||||
raw, err := source.Query(ctx, metric.Query{
|
||||
MetricName: "a.metric", Start: base.Add(-time.Hour), End: base.Add(2 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query reopened raw window: %v", err)
|
||||
}
|
||||
if len(raw) != 0 {
|
||||
t.Fatalf("reopened source raw points = %d, want 0", len(raw))
|
||||
}
|
||||
|
||||
target, err := metric.Open(ctx, metric.SQLite(filepath.Join(t.TempDir(), "target.db"),
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open target: %v", err)
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
type progressEvent struct {
|
||||
metricName string
|
||||
metricIndex, totalMetrics int
|
||||
added int64
|
||||
}
|
||||
var events []progressEvent
|
||||
total, err := migrateBetweenStores(ctx, source, target, func(name string, index, definitions int, added int64) {
|
||||
events = append(events, progressEvent{name, index, definitions, added})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate stores: %v", err)
|
||||
}
|
||||
if total != int64(len(sourceRollups)) {
|
||||
t.Fatalf("migrated rollups = %d, want %d", total, len(sourceRollups))
|
||||
}
|
||||
wantEvents := []progressEvent{
|
||||
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2},
|
||||
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2, added: 2},
|
||||
{metricName: "z.empty", metricIndex: 1, totalMetrics: 2},
|
||||
}
|
||||
if !reflect.DeepEqual(events, wantEvents) {
|
||||
t.Fatalf("migration events = %#v, want %#v", events, wantEvents)
|
||||
}
|
||||
|
||||
for _, want := range definitions {
|
||||
got, err := target.GetMetric(ctx, want.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("get target metric %q: %v", want.Name, err)
|
||||
}
|
||||
if got.Name != want.Name || got.Description != want.Description || got.Type != want.Type || got.Unit != want.Unit || got.RetentionDays != want.RetentionDays || !reflect.DeepEqual(got.Metadata, want.Metadata) {
|
||||
t.Fatalf("target definition = %#v, want mutable fields from %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
exported := exportMigrationTestRollups(t, target, "a.metric")
|
||||
if !reflect.DeepEqual(exported, sourceRollups) {
|
||||
t.Fatalf("target rollups differ:\n got: %#v\nwant: %#v", exported, sourceRollups)
|
||||
}
|
||||
secondTotal, err := MigrateBetweenStores(ctx, source, target)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat migration: %v", err)
|
||||
}
|
||||
if secondTotal != total {
|
||||
t.Fatalf("repeat migrated rollups = %d, want %d", secondTotal, total)
|
||||
}
|
||||
if got := exportMigrationTestRollups(t, target, "a.metric"); !reflect.DeepEqual(got, sourceRollups) {
|
||||
t.Fatalf("repeat migration changed target rollups: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func exportMigrationTestRollups(t *testing.T, store *metric.Store, metricName string) []metric.PersistedRollup {
|
||||
t.Helper()
|
||||
var result []metric.PersistedRollup
|
||||
_, err := store.ExportRollups(context.Background(), metricName, 1, func(batch []metric.PersistedRollup) error {
|
||||
result = append(result, batch...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export %q rollups: %v", metricName, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
metricCredentialPattern = regexp.MustCompile(`(?i)\b(password|passwd)\s*=\s*('[^']*'|"[^"]*"|[^\s,;)&]+)`)
|
||||
metricURLCredentialPattern = regexp.MustCompile(`(?i)([a-z0-9_.-]+):([^@\s]+)@`)
|
||||
)
|
||||
|
||||
// RedactConnectionError removes the supplied DSN and common password fragments
|
||||
// before a connection error is shown to an administrator or written to a log.
|
||||
func RedactConnectionError(message, dsn string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if dsn != "" {
|
||||
message = strings.ReplaceAll(message, dsn, "[redacted]")
|
||||
}
|
||||
message = metricCredentialPattern.ReplaceAllString(message, "$1=[redacted]")
|
||||
return metricURLCredentialPattern.ReplaceAllString(message, "$1:[redacted]@")
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
type reportTrafficState struct {
|
||||
mu sync.Mutex
|
||||
|
||||
reportTrafficValues
|
||||
}
|
||||
|
||||
type reportTrafficValues struct {
|
||||
initialized bool
|
||||
timestamp time.Time
|
||||
hasUp bool
|
||||
totalUp int64
|
||||
hasDown bool
|
||||
totalDown int64
|
||||
}
|
||||
|
||||
var reportTrafficStates sync.Map
|
||||
|
||||
const (
|
||||
reportBatchInterval = 3 * time.Second
|
||||
reportBatchQueueSize = 4096
|
||||
pingBatchMaxRecords = 512
|
||||
reportBatchWriteTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
reportBatcherMu sync.Mutex
|
||||
reportBatcher *reportBatchWorker
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReportBatchQueueFull = errors.New("metric report batch queue is full")
|
||||
ErrReportBatchStopped = errors.New("metric report batcher is stopped")
|
||||
ErrPingBatchQueueFull = errors.New("ping batch queue is full")
|
||||
)
|
||||
|
||||
type reportBatchRequest struct {
|
||||
ctx context.Context
|
||||
done chan error
|
||||
stop bool
|
||||
}
|
||||
|
||||
type reportBatchWorker struct {
|
||||
mu sync.Mutex
|
||||
queue chan v1.Report
|
||||
pingQueue chan models.PingRecord
|
||||
requests chan reportBatchRequest
|
||||
done chan struct{}
|
||||
stopping bool
|
||||
}
|
||||
|
||||
// StartReportBatcher starts the shared report writer. Exact samples are kept in
|
||||
// the short raw window while the same writes update in-memory minute buckets.
|
||||
func StartReportBatcher() {
|
||||
reportBatcherMu.Lock()
|
||||
defer reportBatcherMu.Unlock()
|
||||
if reportBatcher != nil {
|
||||
return
|
||||
}
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v1.Report, reportBatchQueueSize),
|
||||
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
reportBatcher = worker
|
||||
go worker.run()
|
||||
}
|
||||
|
||||
// StopReportBatcher stops the report writer after flushing all queued reports.
|
||||
func StopReportBatcher(ctx context.Context) error {
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
if worker == nil {
|
||||
reportBatcherMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
worker.mu.Lock()
|
||||
worker.stopping = true
|
||||
worker.mu.Unlock()
|
||||
reportBatcherMu.Unlock()
|
||||
|
||||
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1), stop: true}
|
||||
select {
|
||||
case worker.requests <- request:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case err := <-request.done:
|
||||
<-worker.done
|
||||
reportBatcherMu.Lock()
|
||||
if reportBatcher == worker {
|
||||
reportBatcher = nil
|
||||
}
|
||||
reportBatcherMu.Unlock()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// FlushReportBatch synchronously flushes the current queue. It is useful for
|
||||
// controlled handoff points and deterministic tests; normal operation uses the
|
||||
// worker ticker and the active mode's flush interval.
|
||||
func FlushReportBatch(ctx context.Context) error {
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker == nil {
|
||||
return nil
|
||||
}
|
||||
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1)}
|
||||
worker.mu.Lock()
|
||||
stopping := worker.stopping
|
||||
worker.mu.Unlock()
|
||||
if stopping {
|
||||
return ErrReportBatchStopped
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request:
|
||||
case <-worker.done:
|
||||
return ErrReportBatchStopped
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case err := <-request.done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// WriteReport persists one agent report and adds it to in-memory minute
|
||||
// summaries using the same server receive time. Traffic deltas remain summable
|
||||
// after rollup.
|
||||
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
|
||||
if report.UUID == "" {
|
||||
return v1.Report{}, fmt.Errorf("report UUID is required")
|
||||
}
|
||||
if report.UpdatedAt.IsZero() {
|
||||
return v1.Report{}, fmt.Errorf("report receive time is required")
|
||||
}
|
||||
report.UpdatedAt = report.UpdatedAt.UTC()
|
||||
if GetStore() == nil {
|
||||
return v1.Report{}, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker != nil {
|
||||
if err := worker.enqueue(ctx, report); err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
saved, err := writeReportBatch(ctx, []v1.Report{report})
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return saved[0], nil
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) enqueue(ctx context.Context, report v1.Report) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopping {
|
||||
return ErrReportBatchStopped
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case w.queue <- report:
|
||||
return nil
|
||||
default:
|
||||
return ErrReportBatchQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) run() {
|
||||
ticker := time.NewTicker(reportBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var pending []v1.Report
|
||||
var pendingPings []models.PingRecord
|
||||
for {
|
||||
select {
|
||||
case request := <-w.requests:
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
|
||||
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
|
||||
err := errors.Join(
|
||||
writePendingReports(request.ctx, &pending),
|
||||
writePendingPingRecords(request.ctx, &pendingPings),
|
||||
)
|
||||
if request.stop {
|
||||
if err != nil {
|
||||
logger.Errorf("metricstore", "failed to flush metric report batch during shutdown: %v", err)
|
||||
}
|
||||
close(w.done)
|
||||
request.done <- err
|
||||
return
|
||||
}
|
||||
request.done <- err
|
||||
case <-ticker.C:
|
||||
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
|
||||
if err := writePendingPingRecords(context.Background(), &pendingPings); err != nil {
|
||||
logger.Errorf("metricstore", "failed to flush ping batch: %v", err)
|
||||
}
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
|
||||
if err := writePendingReports(context.Background(), &pending); err != nil {
|
||||
logger.Errorf("metricstore", "failed to flush metric report batch: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingRecord) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopping {
|
||||
return ErrReportBatchStopped
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case w.pingQueue <- record:
|
||||
return nil
|
||||
default:
|
||||
return ErrPingBatchQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
capacity := len(queue)
|
||||
if capacity > limit {
|
||||
capacity = limit
|
||||
}
|
||||
if capacity == 0 {
|
||||
return nil
|
||||
}
|
||||
reports := make([]v1.Report, 0, capacity)
|
||||
for len(reports) < limit {
|
||||
select {
|
||||
case report := <-queue:
|
||||
reports = append(reports, report)
|
||||
default:
|
||||
return reports
|
||||
}
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingRecord {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
capacity := len(queue)
|
||||
if capacity > limit {
|
||||
capacity = limit
|
||||
}
|
||||
if capacity == 0 {
|
||||
return nil
|
||||
}
|
||||
records := make([]models.PingRecord, 0, capacity)
|
||||
for len(records) < limit {
|
||||
select {
|
||||
case record := <-queue:
|
||||
records = append(records, record)
|
||||
default:
|
||||
return records
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
|
||||
if len(*pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
batchSize := len(*pending)
|
||||
for len(*pending) > 0 {
|
||||
if batchSize > len(*pending) {
|
||||
batchSize = len(*pending)
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
|
||||
_, err := writeReportBatch(writeCtx, (*pending)[:batchSize])
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*pending = (*pending)[batchSize:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord) error {
|
||||
for len(*pending) > 0 {
|
||||
batchSize := len(*pending)
|
||||
if batchSize > pingBatchMaxRecords {
|
||||
batchSize = pingBatchMaxRecords
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
|
||||
err := writePingRecords(writeCtx, (*pending)[:batchSize])
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*pending = (*pending)[batchSize:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
|
||||
if len(reports) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return nil, fmt.Errorf("wait for metric store operation before writing reports: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
prepared := make([]v1.Report, len(reports))
|
||||
copy(prepared, reports)
|
||||
points := make([]metric.Point, 0, len(reports)*20)
|
||||
pendingStates := make(map[*reportTrafficState]reportTrafficValues)
|
||||
for i, report := range prepared {
|
||||
stateValue, _ := reportTrafficStates.LoadOrStore(report.UUID, &reportTrafficState{})
|
||||
state := stateValue.(*reportTrafficState)
|
||||
values, ok := pendingStates[state]
|
||||
if !ok {
|
||||
state.mu.Lock()
|
||||
values = state.reportTrafficValues
|
||||
state.mu.Unlock()
|
||||
}
|
||||
if !values.initialized {
|
||||
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
|
||||
if err == nil {
|
||||
values.totalUp = totalUp
|
||||
values.hasUp = hasUp
|
||||
} else if ctx.Err() == nil {
|
||||
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
|
||||
}
|
||||
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
|
||||
if err == nil {
|
||||
values.totalDown = totalDown
|
||||
values.hasDown = hasDown
|
||||
} else if ctx.Err() == nil {
|
||||
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values.initialized = true
|
||||
}
|
||||
|
||||
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
|
||||
report.UpdatedAt = values.timestamp.Add(time.Millisecond)
|
||||
}
|
||||
trafficUp := int64(0)
|
||||
if values.hasUp {
|
||||
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
|
||||
}
|
||||
trafficDown := int64(0)
|
||||
if values.hasDown {
|
||||
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
|
||||
}
|
||||
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
|
||||
values.timestamp = report.UpdatedAt
|
||||
values.hasUp = true
|
||||
values.totalUp = report.Network.TotalUp
|
||||
values.hasDown = true
|
||||
values.totalDown = report.Network.TotalDown
|
||||
pendingStates[state] = values
|
||||
prepared[i] = report
|
||||
}
|
||||
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for state, values := range pendingStates {
|
||||
state.mu.Lock()
|
||||
state.reportTrafficValues = values
|
||||
state.mu.Unlock()
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
|
||||
entityID := report.UUID
|
||||
ts := report.UpdatedAt
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
|
||||
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
|
||||
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
|
||||
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
|
||||
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
|
||||
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
|
||||
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
|
||||
{MetricName: MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalDown)},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(trafficUp)},
|
||||
{MetricName: MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(trafficDown)},
|
||||
{MetricName: MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(report.Process)},
|
||||
{MetricName: MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.TCP)},
|
||||
{MetricName: MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.UDP)},
|
||||
}
|
||||
if report.GPU == nil {
|
||||
return points
|
||||
}
|
||||
points = append(points, metric.Point{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: report.GPU.AverageUsage})
|
||||
for deviceIndex, gpu := range report.GPU.DetailedInfo {
|
||||
tags := map[string]string{
|
||||
"device_index": strconv.Itoa(deviceIndex),
|
||||
"device_name": gpu.Name,
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{MetricName: MetricGPUMem, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryUsed), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUMemTotal, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryTotal), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUDeviceUsage, EntityID: entityID, Timestamp: ts, Value: gpu.Utilization, Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUTemp, EntityID: entityID, Timestamp: ts, Value: float64(gpu.Temperature), Tags: tags},
|
||||
)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
|
||||
point, ok, err := s.LatestBefore(ctx, metricName, entityID, before)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(point.Value), true, nil
|
||||
}
|
||||
|
||||
// GetLatestTrafficBefore returns the latest retained upload/download counters
|
||||
// before a boundary, transparently reading raw points or rollup summaries.
|
||||
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
result := make(map[string]models.Record, len(entityIDs))
|
||||
for _, entityID := range entityIDs {
|
||||
if entityID == "" {
|
||||
continue
|
||||
}
|
||||
up, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
down, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasUp && !hasDown {
|
||||
continue
|
||||
}
|
||||
result[entityID] = models.Record{
|
||||
Client: entityID,
|
||||
Time: before.UTC().Add(-time.Nanosecond),
|
||||
NetTotalUp: up,
|
||||
NetTotalDown: down,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TrafficCounterDelta returns a reset-aware increase between two cumulative
|
||||
// traffic counters. After a reset, the current counter is the new increase.
|
||||
func TrafficCounterDelta(current, previous int64) int64 {
|
||||
if current < 0 || previous < 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= previous {
|
||||
return current - previous
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func deleteReportTrafficState(entityID string) {
|
||||
reportTrafficStates.Delete(entityID)
|
||||
}
|
||||
|
||||
func clearReportTrafficStates() {
|
||||
reportTrafficStates.Range(func(key, _ any) bool {
|
||||
reportTrafficStates.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
opts := []metric.Option{metric.WithMaxOpenConns(1)}
|
||||
if policy != nil {
|
||||
opts = append(opts, metric.WithRollupPolicy(*policy))
|
||||
}
|
||||
dsn := fmt.Sprintf("file:report-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn, opts...))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
_ = s.Close()
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
clearReportTrafficStates()
|
||||
t.Cleanup(func() {
|
||||
clearReportTrafficStates()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
s := useReportTestStore(t, &policy)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
now := base.Add(45 * time.Second)
|
||||
|
||||
report := v1.Report{
|
||||
UUID: "node-a",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 12.5},
|
||||
Ram: v1.RamReport{Used: 100, Total: 1000},
|
||||
Swap: v1.RamReport{Used: 20, Total: 200},
|
||||
Load: v1.LoadReport{Load1: 0.5},
|
||||
Disk: v1.DiskReport{Used: 300, Total: 3000},
|
||||
Network: v1.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
|
||||
Process: 7,
|
||||
Connections: v1.ConnectionsReport{TCP: 8, UDP: 9},
|
||||
GPU: &v1.GPUDetailReport{
|
||||
AverageUsage: 25,
|
||||
DetailedInfo: []v1.GPUDeviceInfo{{
|
||||
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Network.TotalUp = 150
|
||||
report.Network.TotalDown = 260
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write second report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Network.TotalUp = 20
|
||||
report.Network.TotalDown = 30
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write reset report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 20})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 30})
|
||||
assertMetricValues(t, s, MetricNetTotalUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{100, 150, 20})
|
||||
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 70, 3)
|
||||
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 90, 3)
|
||||
|
||||
gpuPoints, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPUDeviceUsage,
|
||||
EntityID: report.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Tags: map[string]string{"device_index": "0"},
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU points: %v", err)
|
||||
}
|
||||
if len(gpuPoints) != 3 || !gpuPoints[2].Timestamp.Equal(base.Add(6*time.Second)) || gpuPoints[2].Tags["device_name"] != "GPU 0" {
|
||||
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
|
||||
}
|
||||
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact reports: %v", err)
|
||||
}
|
||||
deleteReportTrafficState(report.UUID)
|
||||
report.UpdatedAt = now
|
||||
report.Network.TotalUp = 35
|
||||
report.Network.TotalDown = 50
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write after restoring rollup baseline: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{15})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
|
||||
}
|
||||
|
||||
func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
timestamp := time.Now().UTC()
|
||||
if _, err := WriteReport(ctx, v1.Report{
|
||||
UUID: "node-without-gpu", UpdatedAt: timestamp,
|
||||
}); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPU, EntityID: "node-without-gpu",
|
||||
Start: timestamp.Add(-time.Second), End: timestamp.Add(time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU metric: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("GPU metric was written without GPU data: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(10 * time.Second)
|
||||
first := v1.Report{
|
||||
UUID: "batched-node",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(3 * time.Second)
|
||||
second.CPU.Usage = 20
|
||||
second.Network.TotalUp = 150
|
||||
second.Network.TotalDown = 260
|
||||
|
||||
if _, err := WriteReport(ctx, first); err != nil {
|
||||
t.Fatalf("queue first report: %v", err)
|
||||
}
|
||||
if _, err := WriteReport(ctx, second); err != nil {
|
||||
t.Fatalf("queue second report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: first.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query before flush: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued reports were written before flush: %#v", points)
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush report batch: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50})
|
||||
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
|
||||
assertMetricAggregate(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggAvg, 15, 2)
|
||||
}
|
||||
|
||||
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
records := []models.PingRecord{
|
||||
{Client: "ping-node", TaskId: 7, Time: base, Value: 24},
|
||||
{Client: "ping-node", TaskId: 7, Time: base.Add(time.Minute), Value: -1},
|
||||
{Client: "ping-node", TaskId: 8, Time: base, Value: 31},
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := WritePingRecord(ctx, record); err != nil {
|
||||
t.Fatalf("queue ping record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{MetricPingLatency, MetricPingLoss} {
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: name,
|
||||
EntityID: "ping-node",
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query queued %s points: %v", name, err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued %s points were written before flush: %#v", name, points)
|
||||
}
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush ping batch: %v", err)
|
||||
}
|
||||
|
||||
latency, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed latency points: %v", err)
|
||||
}
|
||||
if len(latency) != 2 || latency[0].Value != 24 || latency[1].Value != -1 {
|
||||
t.Fatalf("latency points = %#v, want both original samples", latency)
|
||||
}
|
||||
|
||||
loss, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed loss points: %v", err)
|
||||
}
|
||||
if len(loss) != 2 || loss[0].Value != 0 || loss[1].Value != 1 {
|
||||
t.Fatalf("loss points = %#v, want success and loss samples", loss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatchKeepsEverySample(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
pending := []v1.Report{
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 10}, Network: v1.NetworkReport{TotalUp: 100}},
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
|
||||
{UUID: "node-b", UpdatedAt: base, CPU: v1.CPUReport{Usage: 30}, Network: v1.NetworkReport{TotalUp: 200}},
|
||||
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 40}, Network: v1.NetworkReport{TotalUp: 260}},
|
||||
}
|
||||
|
||||
if err := writePendingReports(ctx, &pending); err != nil {
|
||||
t.Fatalf("write report batch: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending reports = %d, want 0", len(pending))
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{30, 40})
|
||||
}
|
||||
|
||||
func TestReportQueueFullReturnsError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
useReportTestStore(t, nil)
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v1.Report, 1),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
worker.queue <- v1.Report{UUID: "already-queued"}
|
||||
reportBatcherMu.Lock()
|
||||
reportBatcher = worker
|
||||
reportBatcherMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
reportBatcherMu.Lock()
|
||||
if reportBatcher == worker {
|
||||
reportBatcher = nil
|
||||
}
|
||||
reportBatcherMu.Unlock()
|
||||
})
|
||||
|
||||
report := v1.Report{
|
||||
UUID: "realtime-node",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := WriteReport(ctx, report)
|
||||
if !errors.Is(err, ErrReportBatchQueueFull) {
|
||||
t.Fatalf("queue-full error = %v, want %v", err, ErrReportBatchQueueFull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute)
|
||||
entityID := "node-aggregation"
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 30},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 100},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 200},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 20},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
records, err := GetRecordsByClientAndTime(ctx, entityID, base, base.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct records: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v, want one bucket", records)
|
||||
}
|
||||
if records[0].Cpu != 20 || records[0].NetTotalUp != 200 || records[0].TrafficUp != 30 {
|
||||
t.Fatalf("unexpected aggregation result: %#v", records[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter", current: 250, previous: 200, want: 50},
|
||||
{name: "unchanged counter", current: 100, previous: 100, want: 0},
|
||||
{name: "counter reset", current: 15, previous: 250, want: 15},
|
||||
{name: "negative current", current: -1, previous: 100, want: 0},
|
||||
{name: "negative previous", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := TrafficCounterDelta(test.current, test.previous); got != test.want {
|
||||
t.Fatalf("TrafficCounterDelta(%d, %d) = %d, want %d", test.current, test.previous, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
local := time.FixedZone("UTC+8", 8*60*60)
|
||||
receiveTime := time.Now().In(local).Add(-10 * time.Second)
|
||||
report := v1.Report{
|
||||
UUID: "utc-report",
|
||||
UpdatedAt: receiveTime,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 1, TotalDown: 2},
|
||||
}
|
||||
|
||||
saved, err := WriteReport(ctx, report)
|
||||
if err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
if !saved.UpdatedAt.Equal(receiveTime) || saved.UpdatedAt.Location() != time.UTC {
|
||||
t.Fatalf("saved receive time = %s (%s), want UTC", saved.UpdatedAt, saved.UpdatedAt.Location())
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: report.UUID,
|
||||
Start: receiveTime.Add(-time.Nanosecond),
|
||||
End: receiveTime.Add(time.Nanosecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query stored point: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.UnixMilli() != receiveTime.UnixMilli() {
|
||||
t.Fatalf("stored points = %#v, want one UTC millisecond point", points)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, want []float64) {
|
||||
t.Helper()
|
||||
points, err := s.Query(context.Background(), metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != len(want) {
|
||||
t.Fatalf("%s point count = %d, want %d: %#v", metricName, len(points), len(want), points)
|
||||
}
|
||||
for i := range want {
|
||||
if points[i].Value != want[i] {
|
||||
t.Fatalf("%s point %d = %v, want %v", metricName, i, points[i].Value, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricAggregate(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, aggregation metric.Aggregation, want float64, wantCount int) {
|
||||
t.Helper()
|
||||
points, err := s.Series(context.Background(), metric.AggregateQuery{
|
||||
Query: metric.Query{MetricName: metricName, EntityID: entityID, Start: start, End: end},
|
||||
Aggregation: aggregation, Interval: time.Minute, PreserveSeries: true,
|
||||
}, end)
|
||||
if err != nil {
|
||||
t.Fatalf("aggregate %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != want || points[0].Count != wantCount {
|
||||
t.Fatalf("aggregate %s = %#v, want value=%v count=%d", metricName, points, want, wantCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// RestructureProgress is the authenticated guide's stable progress payload.
|
||||
type RestructureProgress struct {
|
||||
Phase string
|
||||
CurrentMetric string
|
||||
RowsDone int64
|
||||
RowsTotal int64
|
||||
MetricsDone int
|
||||
MetricsTotal int
|
||||
}
|
||||
|
||||
type RestructureResult struct {
|
||||
BeforeBytes int64
|
||||
AfterBytes int64
|
||||
RowsCopied int64
|
||||
Metrics int
|
||||
}
|
||||
|
||||
// StructureUpgradeRequired checks the configured metric store without creating
|
||||
// tables. It is called before normal metric-store initialization so an existing
|
||||
// installation is never rebuilt at startup without an administrator action.
|
||||
func StructureUpgradeRequired(ctx context.Context) (bool, error) {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return structureUpgradeRequiredForConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func structureUpgradeRequiredForConfig(ctx context.Context, cfg *MetricStoreConfig) (bool, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.NeedsRestructure(ctx)
|
||||
}
|
||||
|
||||
// RestructureConfiguredStore performs the explicit data copy, table swap and
|
||||
// one physical reclaim pass used by the upgrade guide.
|
||||
func RestructureConfiguredStore(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
before, err := store.LegacyStorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
|
||||
}
|
||||
result, err := store.Restructure(ctx, func(progress metric.RestructureProgress) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
|
||||
})
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
if report != nil {
|
||||
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
|
||||
}
|
||||
after, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
|
||||
}
|
||||
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
|
||||
}
|
||||
|
||||
// DiscardConfiguredStoreHistory completes the same schema upgrade as
|
||||
// RestructureConfiguredStore but drops every historical sample instead of
|
||||
// copying it. Metric definitions remain available for new ingest.
|
||||
func DiscardConfiguredStoreHistory(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
before, err := store.LegacyStorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
|
||||
}
|
||||
result, err := store.DiscardHistory(ctx, func(progress metric.RestructureProgress) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
|
||||
})
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
if report != nil {
|
||||
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
|
||||
}
|
||||
after, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
|
||||
}
|
||||
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// store_migration.go
|
||||
//
|
||||
// 面向 WebUI/API 的“存储后端迁移”支撑:把一个 metrics 源库(默认本地 SQLite
|
||||
// ./data/metrics.db)中的全部指标数据搬运到当前正在运行的 metrics 目标库
|
||||
// (通常是用户新配置并热重载后的 MySQL/PostgreSQL)。
|
||||
//
|
||||
// 此迁移仅能由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可查询进度 /
|
||||
// 取消。服务启动和指标库热重载均不会复制历史数据。数据以 upsert 写入,可安全重复执行。
|
||||
//
|
||||
// 典型流程:
|
||||
// 1. 管理员在设置中把 metric_db_dsn 改为 MySQL/PostgreSQL,保存 → 后端连接测试
|
||||
// 通过后热重载,当前 store 切到远端(此时远端为空)。
|
||||
// 2. 管理员调用 startMetricMigration(不带 source 参数),系统以上一个 metrics
|
||||
// 目标(默认 SQLite metrics.db)为源,把历史数据搬运到远端。
|
||||
// 3. 完成后登记目标指纹,供下一次手动迁移默认选择源库。
|
||||
|
||||
// StoreMigrationProgress 描述一次 store-to-store 迁移的实时进度。
|
||||
type StoreMigrationProgress struct {
|
||||
Status string `json:"status"` // idle, running, completed, failed, canceled
|
||||
SourceDriver string `json:"source_driver"` // 源库驱动
|
||||
SourceDSN string `json:"source_dsn"` // 源库 DSN(脱敏后)
|
||||
TargetDriver string `json:"target_driver"` // 目标库驱动
|
||||
TargetDSN string `json:"target_dsn"` // 目标库 DSN(脱敏后)
|
||||
TotalMetrics int `json:"total_metrics"` // 指标定义总数
|
||||
CurrentMetric string `json:"current_metric"` // 当前正在搬运的指标名
|
||||
MetricsDone int `json:"metrics_done"` // 已完成的指标数
|
||||
MigratedPoints int64 `json:"migrated_points"` // 已搬运的采样点数
|
||||
StartTime time.Time `json:"start_time"` // 开始时间
|
||||
EndTime time.Time `json:"end_time"` // 结束时间
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
var (
|
||||
storeMigMu sync.Mutex
|
||||
storeMigProgress StoreMigrationProgress
|
||||
storeMigCancel context.CancelFunc
|
||||
storeMigDone chan struct{}
|
||||
storeClosing bool
|
||||
)
|
||||
|
||||
// IsStoreMigrationRunning 报告是否有 store-to-store 迁移正在运行。
|
||||
func IsStoreMigrationRunning() bool {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeMigCancel != nil
|
||||
}
|
||||
|
||||
// GetStoreMigrationProgress 返回当前 store-to-store 迁移进度快照。
|
||||
func GetStoreMigrationProgress() StoreMigrationProgress {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeMigProgress
|
||||
}
|
||||
|
||||
// ResolveStoreMigrationSourceFingerprint 推断本次 store 迁移的源库指纹。
|
||||
//
|
||||
// 优先级:
|
||||
// 1. 显式传入的 driver+dsn(driver 可留空,交由 DSN 推断)。
|
||||
// 2. 已保存的上一个 metrics 目标指纹(MigrationTargetKey)。
|
||||
// 3. 默认 SQLite(./data/metrics.db)。
|
||||
func ResolveStoreMigrationSourceFingerprint(driver, dsn string) string {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn != "" {
|
||||
d := ResolveDriverFromConfig(driver, dsn)
|
||||
return fmt.Sprintf("%s|%s", d, dsn)
|
||||
}
|
||||
if saved, _ := config.GetAs[string](MigrationTargetKey, ""); saved != "" {
|
||||
return saved
|
||||
}
|
||||
return defaultSQLiteFingerprint()
|
||||
}
|
||||
|
||||
// StartStoreMigration 异步启动一次 store-to-store 迁移:把源库(由 sourceDriver +
|
||||
// sourceDSN 指定,留空则自动推断)的全部指标数据搬运到当前运行中的 metrics 目标库。
|
||||
//
|
||||
// 返回错误表示“未能启动”(例如已有迁移在跑、源与目标相同、目标未初始化等);
|
||||
// 迁移过程中的错误通过 GetStoreMigrationProgress().Status == "failed" 与 Error 暴露。
|
||||
func StartStoreMigration(sourceDriver, sourceDSN string) error {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
targetFP := targetFingerprint(cfg)
|
||||
sourceFP := ResolveStoreMigrationSourceFingerprint(sourceDriver, sourceDSN)
|
||||
|
||||
if sourceFP == targetFP {
|
||||
return fmt.Errorf("source and target metrics database are the same; nothing to migrate")
|
||||
}
|
||||
|
||||
srcCfg, err := configFromFingerprint(sourceFP, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve source metrics store: %w", err)
|
||||
}
|
||||
if !storeOperations.TryAcquire() {
|
||||
return ErrStoreBusy
|
||||
}
|
||||
exclusiveLeaseHandedOff := false
|
||||
defer func() {
|
||||
if !exclusiveLeaseHandedOff {
|
||||
storeOperations.Release()
|
||||
}
|
||||
}()
|
||||
|
||||
storeMu.RLock()
|
||||
dst := store
|
||||
activeFingerprint := storeFingerprint
|
||||
storeMu.RUnlock()
|
||||
if dst == nil {
|
||||
return ErrStoreNotInitialized
|
||||
}
|
||||
if activeFingerprint != targetFP {
|
||||
return fmt.Errorf("active metric store does not match the configured migration target; wait for hot reload and retry")
|
||||
}
|
||||
|
||||
storeMigMu.Lock()
|
||||
if storeClosing {
|
||||
storeMigMu.Unlock()
|
||||
return ErrStoreBusy
|
||||
}
|
||||
if storeMigCancel != nil {
|
||||
storeMigMu.Unlock()
|
||||
return fmt.Errorf("a store migration is already in progress")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
storeMigCancel = cancel
|
||||
storeMigDone = done
|
||||
storeMigProgress = StoreMigrationProgress{
|
||||
Status: "running",
|
||||
SourceDriver: string(ResolveDriverFromConfig(srcCfg.Driver, srcCfg.DSN)),
|
||||
SourceDSN: maskDSN(srcCfg.DSN),
|
||||
TargetDriver: string(ResolveDriverFromConfig(cfg.Driver, cfg.DSN)),
|
||||
TargetDSN: maskDSN(cfg.DSN),
|
||||
StartTime: time.Now().UTC(),
|
||||
}
|
||||
storeMigMu.Unlock()
|
||||
|
||||
exclusiveLeaseHandedOff = true
|
||||
go runStoreMigration(ctx, cancel, done, srcCfg, cfg, dst, targetFP)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runStoreMigration 执行实际的搬运逻辑(在独立 goroutine 中)。
|
||||
func runStoreMigration(ctx context.Context, cancel context.CancelFunc, done chan struct{}, srcCfg *MetricStoreConfig, cfg *MetricStoreConfig, dst *metric.Store, targetFP string) {
|
||||
defer func() {
|
||||
cancel()
|
||||
storeOperations.Release()
|
||||
|
||||
storeMigMu.Lock()
|
||||
storeMigCancel = nil
|
||||
storeMigDone = nil
|
||||
close(done)
|
||||
storeMigMu.Unlock()
|
||||
}()
|
||||
|
||||
src, err := openSourceStore(ctx, srcCfg)
|
||||
if err != nil {
|
||||
finishStoreMigration("failed", fmt.Errorf("open source metrics store: %w", err))
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
observe := func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64) {
|
||||
storeMigMu.Lock()
|
||||
storeMigProgress.CurrentMetric = currentMetric
|
||||
storeMigProgress.MetricsDone = metricIndex
|
||||
storeMigProgress.TotalMetrics = totalMetrics
|
||||
storeMigProgress.MigratedPoints += addedPoints
|
||||
storeMigMu.Unlock()
|
||||
}
|
||||
|
||||
total, err := migrateBetweenStores(ctx, src, dst, observe)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
finishStoreMigration("canceled", nil)
|
||||
logger.Warnf("metricstore", "[store-migration] canceled after %d points", total)
|
||||
return
|
||||
}
|
||||
finishStoreMigration("failed", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 搬运成功:登记当前目标指纹,供下一次手动迁移推断源库。
|
||||
if err := config.Set(MigrationTargetKey, targetFP); err != nil {
|
||||
logger.Errorf("metricstore", "[store-migration] failed to persist migration target fingerprint: %v", err)
|
||||
}
|
||||
finishStoreMigration("completed", nil)
|
||||
logger.Infof("metricstore", "[store-migration] completed via API (%d points) target_driver=%s", total, ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
}
|
||||
|
||||
// finishStoreMigration 统一收尾:设置终态状态、结束时间与错误信息。
|
||||
func finishStoreMigration(status string, err error) {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
storeMigProgress.Status = status
|
||||
storeMigProgress.EndTime = time.Now().UTC()
|
||||
if err != nil {
|
||||
storeMigProgress.Error = err.Error()
|
||||
}
|
||||
// 完成/取消时把当前指标标记为已全部处理,便于前端进度条归位。
|
||||
if status == "completed" && storeMigProgress.TotalMetrics > 0 {
|
||||
storeMigProgress.MetricsDone = storeMigProgress.TotalMetrics
|
||||
}
|
||||
}
|
||||
|
||||
// CancelStoreMigration 请求取消正在运行的 store-to-store 迁移,并等待其退出。
|
||||
// 由于写入是幂等 upsert,取消后可安全重新发起,不会产生重复数据。
|
||||
func CancelStoreMigration() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return stopStoreMigration(ctx, true)
|
||||
}
|
||||
|
||||
func stopStoreMigration(ctx context.Context, requireRunning bool) error {
|
||||
storeMigMu.Lock()
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
if requireRunning {
|
||||
return fmt.Errorf("no store migration is currently running")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func stopStoreMigrationForClose(ctx context.Context) error {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = true
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// StopStoreMigrationForShutdown prevents a new store migration and waits for
|
||||
// an active one to release its exclusive lease before report batches flush.
|
||||
func StopStoreMigrationForShutdown(ctx context.Context) error {
|
||||
return stopStoreMigrationForClose(ctx)
|
||||
}
|
||||
|
||||
func clearStoreClosing() {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = false
|
||||
storeMigMu.Unlock()
|
||||
}
|
||||
|
||||
func isStoreClosing() bool {
|
||||
storeMigMu.Lock()
|
||||
defer storeMigMu.Unlock()
|
||||
return storeClosing
|
||||
}
|
||||
|
||||
// maskDSN 对 DSN 做粗粒度脱敏,避免在 API 响应/日志中泄露密码。
|
||||
// 处理两类常见格式:URL(scheme://user:pass@host/...)与 key=value(password=...)。
|
||||
func maskDSN(dsn string) string {
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return ""
|
||||
}
|
||||
// key=value 形式:屏蔽 password=... 段。
|
||||
if strings.Contains(dsn, "password=") {
|
||||
parts := strings.Fields(dsn)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(strings.ToLower(p), "password=") {
|
||||
parts[i] = "password=***"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
// URL / user:pass@host 形式:屏蔽 user:pass@ 中的密码。
|
||||
if at := strings.LastIndex(dsn, "@"); at > 0 {
|
||||
head := dsn[:at]
|
||||
tail := dsn[at:]
|
||||
if colon := strings.LastIndex(head, ":"); colon >= 0 {
|
||||
// 保留 scheme://user,屏蔽密码。
|
||||
return head[:colon] + ":***" + tail
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UTCTime 是一个自定义类型,用于在 GORM 中处理 time.Time。
|
||||
// 它强制所有时间都以指定时区(或 UTC)的字符串格式存取。
|
||||
type UTCTime time.Time
|
||||
|
||||
// Value 实现 driver.Valuer 接口,将 UTCTime 转换为数据库可以识别的 TEXT 类型。
|
||||
// 所有时间在存储到数据库前,都转换为 UTC 并以标准格式存储。
|
||||
func (t UTCTime) Value() (driver.Value, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return nil, nil // GORM 默认会将零值时间存储为 NULL,这里保持一致
|
||||
}
|
||||
// 将时间转换为应用程序指定的时区,并格式化为不带时区信息的字符串。
|
||||
// 这样数据库中存储的是 'YYYY-MM-DD HH:MM:SS.NNNNNNN' 格式。
|
||||
// 注意:之前你遇到的格式是 "2025-06-14 23:51:17.3022328+08:00"
|
||||
// 这种格式带有微秒和时区偏移。为了兼容旧数据读取,我们这里也用精确格式。
|
||||
// 但存储时,为了避免时区混乱,建议统一为某个特定时区的纯时间戳字符串。
|
||||
// 这里我建议统一存储为 UTC 时间的精确字符串。
|
||||
// "2006-01-02 15:04:05.0000000" 这个布局对应不带时区后缀的纳秒精度。
|
||||
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner 接口,将数据库中的 TEXT 值扫描到 UTCTime。
|
||||
// 所有时间字符串在读取时,都会被尝试解析为指定时区(或 UTC)的 time.Time 对象。
|
||||
func (t *UTCTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
*t = UTCTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
// If the driver already parsed it to time.Time, use it directly.
|
||||
// Then convert to the application's desired location.
|
||||
*t = UTCTime(val.In(GetAppLocation()))
|
||||
return nil
|
||||
case []byte:
|
||||
// If it's still a byte slice (string), parse it.
|
||||
timeStr := strings.TrimSpace(string(val))
|
||||
if timeStr == "" {
|
||||
*t = UTCTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedTime time.Time
|
||||
var err error
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.0000000-07:00", // Your observed precise format with timezone
|
||||
"2006-01-02 15:04:05-07:00", // With seconds and timezone
|
||||
time.RFC3339Nano, // RFC3339 with nanoseconds
|
||||
time.RFC3339, // RFC3339
|
||||
"2006-01-02 15:04:05.0000000", // Nanoseconds, no timezone
|
||||
"2006-01-02 15:04:05", // Standard, no timezone
|
||||
"2006-01-02", // Date only
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
// time.Parse handles strings that include timezone info correctly.
|
||||
parsedTime, err = time.Parse(layout, timeStr)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析时间字符串 '%s' 为 UTCTime: %w", timeStr, err)
|
||||
}
|
||||
|
||||
*t = UTCTime(parsedTime.In(GetAppLocation()))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("UTCTime scan source was not []byte or time.Time: %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler interface.
|
||||
// This method defines how UTCTime should be serialized to JSON.
|
||||
func (t UTCTime) MarshalJSON() ([]byte, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
// 如果是零值时间,返回 JSON null。
|
||||
// 这样 API 返回的就是 "field": null,而不是 "field": {}。
|
||||
return []byte("null"), nil
|
||||
}
|
||||
// 将时间转换为应用程序指定的时区,并格式化为 JSON 字符串。
|
||||
// 使用双引号包裹,符合 JSON 字符串规范。
|
||||
// 同样使用之前定义的精确格式,便于客户端解析。
|
||||
formattedTime := time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000-07:00")
|
||||
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
|
||||
}
|
||||
|
||||
// ToTime 将 UTCTime 转换为 Go 的 time.Time 类型。
|
||||
func (t UTCTime) ToTime() time.Time {
|
||||
return time.Time(t)
|
||||
}
|
||||
|
||||
// FromTime 将 Go 的 time.Time 类型转换为 UTCTime。
|
||||
func FromTime(t time.Time) UTCTime {
|
||||
return UTCTime(t)
|
||||
}
|
||||
|
||||
// Now returns the current time in the application's configured location.
|
||||
func Now() UTCTime {
|
||||
return UTCTime(time.Now().In(GetAppLocation()))
|
||||
}
|
||||
|
||||
// AppLocation 用于存储应用程序全局使用的时区。
|
||||
// 首次获取后会缓存,避免重复加载。
|
||||
var (
|
||||
appLocation *time.Location
|
||||
locationOnce sync.Once
|
||||
defaultLocation = time.UTC // 默认时区设为 UTC
|
||||
)
|
||||
|
||||
// GetAppLocation 获取应用程序的全局时区。
|
||||
// 优先从环境变量 "TZ" 获取,否则使用默认的 UTC。
|
||||
func GetAppLocation() *time.Location {
|
||||
locationOnce.Do(func() {
|
||||
tz := os.Getenv("TZ")
|
||||
if tz != "" {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load timezone from environment variable TZ='%s', using default UTC. Error: %v\n", tz, err)
|
||||
appLocation = defaultLocation
|
||||
} else {
|
||||
appLocation = loc
|
||||
log.Printf("Info: Application timezone set from environment variable TZ='%s' (%s).\n", tz, appLocation.String())
|
||||
}
|
||||
} else {
|
||||
appLocation = defaultLocation
|
||||
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
|
||||
}
|
||||
time.Local = appLocation // 设置全局时区
|
||||
})
|
||||
return appLocation
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package models
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
// OAuth 配置
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// Telegram Bot 配置
|
||||
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
// Email 配置
|
||||
EmailHost string `json:"email_host" gorm:"type:varchar(255)"` // 邮件服务器地址
|
||||
EmailPort int `json:"email_port" gorm:"default:587"` // 邮件服务器端口,默认587
|
||||
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"` // 邮箱用户名
|
||||
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"` // 邮箱密码
|
||||
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"` // 邮箱发送者地址
|
||||
EmailUseSSL bool `json:"email_use_ssl" gorm:"default:true"` // 是否使用 SSL/TLS 连接,默认 true
|
||||
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"` // 邮件接收者地址
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
// Record
|
||||
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
|
||||
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
|
||||
CreatedAt UTCTime
|
||||
UpdatedAt UTCTime
|
||||
}
|
||||
@@ -1,10 +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"`
|
||||
Time UTCTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
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,14 @@
|
||||
package messageevent
|
||||
|
||||
const (
|
||||
Offline = "Offline"
|
||||
Online = "Online"
|
||||
Expire = "Expire"
|
||||
Renew = "Renew"
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
DReport = "DReport" // 日报
|
||||
WReport = "WReport" // 周报
|
||||
MReport = "MReport" // 月报
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type MessageSenderProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event string `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
+95
-60
@@ -4,36 +4,44 @@ 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"`
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `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
|
||||
@@ -45,45 +53,60 @@ type User struct {
|
||||
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 UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
type Session struct {
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
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 UTCTime `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 UTCTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
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"`
|
||||
Time UTCTime `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)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
|
||||
Swap int64 `json:"swap" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
|
||||
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
|
||||
Disk int64 `json:"disk" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
NetIn int64 `json:"net_in" gorm:"type:bigint"`
|
||||
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"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
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)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
|
||||
Swap int64 `json:"swap" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
|
||||
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
|
||||
Disk int64 `json:"disk" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
NetIn int64 `json:"net_in" gorm:"type:bigint"`
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
|
||||
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
|
||||
}
|
||||
|
||||
// GPURecord logs individual GPU metrics over time
|
||||
type GPURecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time time.Time `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
@@ -91,9 +114,21 @@ type Record struct {
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
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 UTCTime `json:"last_notified"` // 上次通知时间
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
@@ -19,5 +21,15 @@ type LoadNotification struct {
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
|
||||
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:"{}"`
|
||||
}
|
||||
+30
-12
@@ -1,19 +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 UTCTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
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"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
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"`
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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"`
|
||||
@@ -8,11 +10,11 @@ type Task struct {
|
||||
}
|
||||
|
||||
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 *UTCTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt UTCTime `json:"created_at" gorm:"type:timestamp"`
|
||||
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,146 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeConfigurationManaged = "managed"
|
||||
ThemeConfigurationRaw = "raw"
|
||||
ThemeConfigurationRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name any `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help any `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
func (t Theme) ConfigurationType() string {
|
||||
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
|
||||
if typ == "" {
|
||||
return ThemeConfigurationManaged
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func (t Theme) RawHTML() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRaw {
|
||||
return "", false
|
||||
}
|
||||
return configurationDataString(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) RedirectTarget() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRedirect {
|
||||
return "", false
|
||||
}
|
||||
return NormalizeThemeRedirectTarget(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) ValidateConfiguration() error {
|
||||
switch t.ConfigurationType() {
|
||||
case ThemeConfigurationManaged:
|
||||
return nil
|
||||
case ThemeConfigurationRaw:
|
||||
html, ok := t.RawHTML()
|
||||
if !ok || strings.TrimSpace(html) == "" {
|
||||
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
|
||||
}
|
||||
return nil
|
||||
case ThemeConfigurationRedirect:
|
||||
if _, ok := t.RedirectTarget(); !ok {
|
||||
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeThemeRedirectTarget(data any) (string, bool) {
|
||||
target, ok := configurationDataString(data)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cleanInputPath := parsed.Path
|
||||
if strings.HasPrefix(cleanInputPath, "/") {
|
||||
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
|
||||
} else {
|
||||
for strings.HasPrefix(cleanInputPath, "../") {
|
||||
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
|
||||
}
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(cleanInputPath, "/") {
|
||||
if segment == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath := cleanInputPath
|
||||
if cleanPath == "" {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
if cleanPath == "." {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := url.URL{
|
||||
Path: cleanPath,
|
||||
RawQuery: parsed.RawQuery,
|
||||
Fragment: parsed.Fragment,
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func configurationDataString(data any) (string, bool) {
|
||||
value, ok := data.(string)
|
||||
return value, ok
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThemeValidateConfiguration(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
theme Theme
|
||||
wantErr bool
|
||||
}{
|
||||
"managed empty data": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: ThemeConfigurationManaged},
|
||||
},
|
||||
},
|
||||
"missing type defaults to managed": {
|
||||
theme: Theme{},
|
||||
},
|
||||
"raw html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: "<!doctype html><title>raw</title>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"raw rejects empty html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: " ",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect relative path": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/dashboard?tab=nodes",
|
||||
},
|
||||
},
|
||||
},
|
||||
"redirect rejects absolute url": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "https://example.com",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect rejects parent traversal": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/../admin",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"unknown type": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: "hosted"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := tt.theme.ValidateConfiguration()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeThemeRedirectTarget(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
"leading slash": {
|
||||
input: "/nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"missing leading slash": {
|
||||
input: "nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"parent prefix points to root path": {
|
||||
input: "../settings",
|
||||
want: "/settings",
|
||||
ok: true,
|
||||
},
|
||||
"multiple parent prefixes point to root path": {
|
||||
input: "../../settings/site",
|
||||
want: "/settings/site",
|
||||
ok: true,
|
||||
},
|
||||
"query and fragment": {
|
||||
input: "nodes?id=1#cpu",
|
||||
want: "/nodes?id=1#cpu",
|
||||
ok: true,
|
||||
},
|
||||
"absolute parent traversal": {
|
||||
input: "/../admin",
|
||||
},
|
||||
"nested parent traversal": {
|
||||
input: "nodes/../admin",
|
||||
},
|
||||
"protocol relative": {
|
||||
input: "//example.com",
|
||||
},
|
||||
"backslash": {
|
||||
input: `\admin`,
|
||||
},
|
||||
"absolute url": {
|
||||
input: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := NormalizeThemeRedirectTarget(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("target = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Log{}); err != nil {
|
||||
t.Fatalf("migrate log: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
|
||||
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatalf("create log: %v", err)
|
||||
}
|
||||
var loaded Log
|
||||
if err := db.First(&loaded, entry.ID).Error; err != nil {
|
||||
t.Fatalf("load log: %v", err)
|
||||
}
|
||||
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
|
||||
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(loaded)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Client{}); err != nil {
|
||||
t.Fatalf("migrate client: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
|
||||
t.Fatalf("insert client: %v", err)
|
||||
}
|
||||
|
||||
var client Client
|
||||
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
|
||||
t.Fatalf("load client: %v", err)
|
||||
}
|
||||
if client.ExpiredAt != nil {
|
||||
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
|
||||
}
|
||||
encoded, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal client: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"expired_at":null`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package notification
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
load_notification "github.com/komari-monitor/komari/utils/notification"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -64,5 +64,5 @@ func ReloadLoadNotificationSchedule() error {
|
||||
if err := db.Find(&loadNotifications).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return load_notification.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
|
||||
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
|
||||
if daily {
|
||||
query = query.Where("daily = ?", true)
|
||||
} else if weekly {
|
||||
query = query.Where("weekly = ?", true)
|
||||
} else if monthly {
|
||||
query = query.Where("monthly = ?", true)
|
||||
}
|
||||
err := query.Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
|
||||
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Enable: true,
|
||||
}})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
|
||||
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
|
||||
assert.Error(t, err)
|
||||
|
||||
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Daily: true,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, notifications, 1)
|
||||
assert.Equal(t, "client-a", notifications[0].Client)
|
||||
assert.True(t, notifications[0].Enable)
|
||||
}
|
||||
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user