mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-07 14:50:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
567d2b66fb | ||
|
|
473dbb9057 | ||
|
|
89bd47d5df | ||
|
|
10c4a9dafc | ||
|
|
f4d73bf8c9 | ||
|
|
441e2e9194 | ||
|
|
c15881c463 | ||
|
|
f5acbd2efe | ||
|
|
e07378c92b | ||
|
|
cd38d32a7d | ||
|
|
53f326390e | ||
|
|
651d24128c | ||
|
|
2660026917 | ||
|
|
ac7ace27cb | ||
|
|
1de008269b | ||
|
|
4bdbff0377 | ||
|
|
3415c06256 | ||
|
|
9f3da4c17a | ||
|
|
3d0c83557e | ||
|
|
e319eb7f20 | ||
|
|
7f007b7eb9 | ||
|
|
9e665005e6 | ||
|
|
b5b32b5f64 | ||
|
|
89a699b6cb | ||
|
|
79cbf2d2d1 | ||
|
|
ab00c9529e | ||
|
|
21500a6727 | ||
|
|
066b944789 | ||
|
|
d7dd816a26 | ||
|
|
c5b464c000 | ||
|
|
645c733393 | ||
|
|
85088e64ca | ||
|
|
896b3e45fc | ||
|
|
490f951b59 | ||
|
|
69c96255d7 | ||
|
|
487542e55d | ||
|
|
ca8fe76c06 | ||
|
|
e6e3619cd2 | ||
|
|
92ab67d5f3 | ||
|
|
77f5bef1a5 | ||
|
|
58e43a0862 | ||
|
|
80c1f2747a | ||
|
|
b7b2d8b050 | ||
|
|
b9eabd7cdc | ||
|
|
0a19465140 | ||
|
|
e874b5db18 | ||
|
|
23e7d97e3c | ||
|
|
a222de2c55 | ||
|
|
bb1eed1ccb | ||
|
|
c34bc8da69 | ||
|
|
249c4376f1 | ||
|
|
d36bde7d94 | ||
|
|
493d2a2707 | ||
|
|
93a9fbe5cc | ||
|
|
b070f33bf9 | ||
|
|
ee5480dba7 | ||
|
|
2e9ef3d5ba | ||
|
|
1061b84087 | ||
|
|
0dc80f7696 | ||
|
|
212e3c689b | ||
|
|
488ca451ae | ||
|
|
f50c167b37 | ||
|
|
4e91d62d48 | ||
|
|
a1b0b1e177 | ||
|
|
147dd2011a | ||
|
|
d24b71d4af | ||
|
|
483e3a94e9 | ||
|
|
e612f5ed8f | ||
|
|
78f3c8fd4b | ||
|
|
5a4e073d63 | ||
|
|
22cbfecfa9 | ||
|
|
d1b059fc2f | ||
|
|
cf5c410aa6 | ||
|
|
9c46673a1a | ||
|
|
45c27ae981 | ||
|
|
b62fb1f708 | ||
|
|
579c196643 | ||
|
|
e8da6e213a | ||
|
|
d9426e43a9 | ||
|
|
aad0274d56 | ||
|
|
d65f7ca924 | ||
|
|
a4d95640a5 | ||
|
|
adda3a2488 | ||
|
|
34654c0969 | ||
|
|
b708f9817d | ||
|
|
1210a1cbd9 | ||
|
|
24f1a673b9 | ||
|
|
a3162e1992 | ||
|
|
17f904e4ab | ||
|
|
1324f4fa21 | ||
|
|
cf15144bb9 | ||
|
|
946b27b3b8 | ||
|
|
aed5254cf0 | ||
|
|
7b8922b7ac | ||
|
|
733ad4c6c0 | ||
|
|
ef98ddd1d3 | ||
|
|
ca28c40aca | ||
|
|
0af5903bfe | ||
|
|
717b7b4a88 | ||
|
|
05eec9515b | ||
|
|
877852fc29 | ||
|
|
dbb9075a52 | ||
|
|
eafcf7fe76 | ||
|
|
094ce6c1ba | ||
|
|
2eed2cf7ba | ||
|
|
88b9d3bdac | ||
|
|
9c87227613 | ||
|
|
bc737b1ef9 | ||
|
|
911d990690 | ||
|
|
0f7cd5f438 | ||
|
|
354903b97b | ||
|
|
b432396111 | ||
|
|
491c0ad531 | ||
|
|
1540b78487 | ||
|
|
11f43b116d | ||
|
|
a0760c8e93 | ||
|
|
85ecfc3500 | ||
|
|
4c2308622a | ||
|
|
25b6a25f9a | ||
|
|
7138c22d02 | ||
|
|
a06a5c9e72 | ||
|
|
668eb63668 | ||
|
|
7d75f1ba37 | ||
|
|
2c7b59985e | ||
|
|
b616eeb2d4 | ||
|
|
0f0e359c63 | ||
|
|
1b83644788 | ||
|
|
9a9200744e | ||
|
|
e763bc12c4 | ||
|
|
8d7b47a7de | ||
|
|
d7b3c3443d | ||
|
|
8559083d66 | ||
|
|
96ef3c9fd5 | ||
|
|
3cace1fce0 | ||
|
|
f2adfcc052 | ||
|
|
b31bfa8ba9 | ||
|
|
b098509df4 | ||
|
|
2df23b6ddb | ||
|
|
6dbfdf7f89 | ||
|
|
5d8b0d9c91 | ||
|
|
fa00885d8b | ||
|
|
f3b52b3282 | ||
|
|
10ddde6592 | ||
|
|
9dff938db3 | ||
|
|
44ae7b218f | ||
|
|
f6fa61fe56 | ||
|
|
207c3fa75d | ||
|
|
027c6c8c14 | ||
|
|
709fafd64a | ||
|
|
772e43d5b4 | ||
|
|
2c0f01be1a | ||
|
|
f7bec7189c | ||
|
|
1730f849ed | ||
|
|
1d46194f99 | ||
|
|
8d652049af | ||
|
|
7af2a956d2 | ||
|
|
45b23a04c9 | ||
|
|
1a7c5990ae | ||
|
|
9133dac3d0 | ||
|
|
2709166a5b | ||
|
|
fcb5284d57 | ||
|
|
92d511253f | ||
|
|
9874c4d339 | ||
|
|
86e97c66b9 | ||
|
|
f2afed643e | ||
|
|
f502e3df76 | ||
|
|
7491da96e6 | ||
|
|
0b887689f0 | ||
|
|
9cce96c80c | ||
|
|
2711d27c03 | ||
|
|
db3f31aba5 | ||
|
|
29a7e3607f | ||
|
|
d7f6c2920d | ||
|
|
6bff7e86f6 | ||
|
|
a7a78a316e | ||
|
|
17ff74854a | ||
|
|
a101601cf4 | ||
|
|
1904192f55 | ||
|
|
7fd49ab55c | ||
|
|
3d7138ea30 | ||
|
|
835f8f17ec | ||
|
|
488c1afcab | ||
|
|
1fd59efe6c | ||
|
|
9cf0366aca | ||
|
|
15d7480547 | ||
|
|
819c55d691 | ||
|
|
67b4c5ebd8 | ||
|
|
4bd9fda728 | ||
|
|
33b8308b3f | ||
|
|
57cf9c243f | ||
|
|
f497a1e48b | ||
|
|
f23fa95a56 | ||
|
|
8e8033bb35 | ||
|
|
71b0903b0e | ||
|
|
d0eefe2dd2 | ||
|
|
2c547bab68 | ||
|
|
2dc04b5ecf | ||
|
|
e2dae1ec7b | ||
|
|
59d923e168 | ||
|
|
51844c27f1 | ||
|
|
53171affca | ||
|
|
d31d12e59f | ||
|
|
3715f2b3f9 | ||
|
|
cc3d54bff4 | ||
|
|
ef376a88b8 | ||
|
|
e639238f0e | ||
|
|
cf8241a77a | ||
|
|
eb87a4fc33 | ||
|
|
0727fb4343 | ||
|
|
d55d759fec | ||
|
|
aad279ce55 | ||
|
|
e9b1a6b2cd | ||
|
|
bd5a6934e1 | ||
|
|
2f04008453 | ||
|
|
fbaa410fd4 | ||
|
|
1d4f6f2814 | ||
|
|
ad2f5160fb | ||
|
|
d284405310 | ||
|
|
ff172ccbf4 | ||
|
|
14635948b6 | ||
|
|
012fd1dcc2 | ||
|
|
94fa5862b1 | ||
|
|
e7ec6c71f7 | ||
|
|
59b187b2a6 | ||
|
|
5e7cb0f9f1 | ||
|
|
dfe51b0dc5 | ||
|
|
7a4dd4bf66 | ||
|
|
0b34beea59 | ||
|
|
90ee9bc731 | ||
|
|
046f38e7c4 | ||
|
|
eb733cea45 | ||
|
|
eb68f0c629 | ||
|
|
1cbcac4a00 | ||
|
|
2d6d58edab | ||
|
|
c01fadbb48 | ||
|
|
41ef2e239d | ||
|
|
7176180343 | ||
|
|
0b9a01c8ac | ||
|
|
50ef62a64a | ||
|
|
384fa4bbe1 | ||
|
|
10f84a8052 | ||
|
|
53bd83af8b | ||
|
|
53577e21e7 | ||
|
|
46c0cb9bfb | ||
|
|
ac8ee8f9fa | ||
|
|
787e8bdd80 | ||
|
|
83a39e3143 | ||
|
|
5e2ed79929 | ||
|
|
c8f0ba926d | ||
|
|
17e0e14aef | ||
|
|
731602a80d | ||
|
|
37f0e449de | ||
|
|
56dd512d3c | ||
|
|
16f11b052a | ||
|
|
06bae7efcb | ||
|
|
39b017620f | ||
|
|
45e8a3a530 | ||
|
|
8dd64a9fa3 | ||
|
|
c1e8b0779f | ||
|
|
d16daf1862 | ||
|
|
491b092ac1 | ||
|
|
e2304c6b1b | ||
|
|
ea52f61a2c | ||
|
|
3db6edcc3d | ||
|
|
2045dd3f30 | ||
|
|
7a37c93a02 | ||
|
|
1d49300f8c | ||
|
|
f5641df0d9 | ||
|
|
b9c1e58703 | ||
|
|
22b9fada55 | ||
|
|
d34b286c4f | ||
|
|
6dab3c8450 | ||
|
|
32dd5c9414 | ||
|
|
2f09d28b94 | ||
|
|
77ce900bbf | ||
|
|
0675634b27 | ||
|
|
74db186ab3 | ||
|
|
1a5bc31b9b | ||
|
|
f6a94db702 | ||
|
|
6213ce7556 | ||
|
|
ff91e548b8 | ||
|
|
6d59070184 | ||
|
|
c8126e551f | ||
|
|
589eae0d3f | ||
|
|
8e25bc9ce3 | ||
|
|
9aa4dcc206 | ||
|
|
b2b5966699 | ||
|
|
e9c7e0031c | ||
|
|
1e3d29ffdb | ||
|
|
84670ac231 | ||
|
|
6b17c5c498 | ||
|
|
eed9820ce6 | ||
|
|
858d914ae3 | ||
|
|
be7c3812db | ||
|
|
44c65f1eb8 | ||
|
|
dd742c1038 | ||
|
|
d19b64ec1d | ||
|
|
6ea22e3241 | ||
|
|
ea99966e7a | ||
|
|
fa13d4d1f9 | ||
|
|
15e4f583e0 | ||
|
|
3fbbc7bdce | ||
|
|
5fcd1d313c | ||
|
|
967b7ab8a7 | ||
|
|
d113dde3cc | ||
|
|
60c24a5ee1 | ||
|
|
d5b8982763 | ||
|
|
88a441da23 | ||
|
|
03f27e7eb1 | ||
|
|
6bba4526f0 | ||
|
|
ebb84862a2 | ||
|
|
28480a93d4 | ||
|
|
5c7c5a4e99 | ||
|
|
90ee1da9c7 | ||
|
|
596dbd0c2d | ||
|
|
de5db97505 | ||
|
|
0db40294ba | ||
|
|
74cd074c98 | ||
|
|
bb43b25e37 | ||
|
|
b28431e4bd | ||
|
|
ee999810af | ||
|
|
860f498e21 | ||
|
|
798e900a9a | ||
|
|
9617cf575f | ||
|
|
0ebba0f591 | ||
|
|
01fd429ec3 | ||
|
|
701aacf45c | ||
|
|
2120168d1f | ||
|
|
171e4ceece | ||
|
|
1d4c394ad8 | ||
|
|
837313e146 | ||
|
|
3e415da8d4 | ||
|
|
a125466b75 | ||
|
|
a9306cd651 | ||
|
|
6af72f41c0 | ||
|
|
e9e04593c7 | ||
|
|
43b39c9f68 | ||
|
|
8d5ce55301 | ||
|
|
e72d461869 | ||
|
|
5cc4b518ba | ||
|
|
8aed055cc4 | ||
|
|
a306b6911a | ||
|
|
776dfee23b | ||
|
|
02e01a1d7c | ||
|
|
6b0b5b4855 | ||
|
|
9dd09731bd | ||
|
|
963c829164 | ||
|
|
d01fafbf0f | ||
|
|
729db05346 | ||
|
|
70dc06a761 | ||
|
|
be059b7aa3 | ||
|
|
b80c917f25 | ||
|
|
6b74bf90ce | ||
|
|
a2e4665ff5 | ||
|
|
2c9bd9c847 | ||
|
|
54eb957618 | ||
|
|
370f0ff815 | ||
|
|
9bd9a280e5 | ||
|
|
daf6ae291f | ||
|
|
3d774eec81 | ||
|
|
129e0be151 | ||
|
|
862657ea86 | ||
|
|
70b519a345 | ||
|
|
6600e4014d | ||
|
|
b8a6f18a77 | ||
|
|
03e08cdfbb | ||
|
|
b07f7345ee | ||
|
|
e85996178a | ||
|
|
5547fcd067 | ||
|
|
aef4344997 | ||
|
|
061197cd77 | ||
|
|
c00d1d0cb2 | ||
|
|
b6a7511e33 | ||
|
|
13450a50eb | ||
|
|
eb883ccc6f | ||
|
|
2ae9b3516c | ||
|
|
7dc2a0294e | ||
|
|
f97b91c19c | ||
|
|
bfac98dc67 | ||
|
|
0f0f9f5985 | ||
|
|
efebe66518 | ||
|
|
37a05e8f3d | ||
|
|
7473c438c2 | ||
|
|
6e4e6d0653 | ||
|
|
c09bec5c20 | ||
|
|
14ce2b8f53 | ||
|
|
8ecb6aa7aa | ||
|
|
cedc144173 | ||
|
|
434e09ae95 | ||
|
|
06ffc44530 | ||
|
|
a7c24e2323 | ||
|
|
726cfa513e | ||
|
|
62a1317d88 | ||
|
|
1221feae45 | ||
|
|
884c5686bd | ||
|
|
9b82a19528 | ||
|
|
2db4dfb942 | ||
|
|
09df09c29a | ||
|
|
215fdc822c | ||
|
|
1572b92751 | ||
|
|
8da28d6b95 | ||
|
|
6f24268683 | ||
|
|
abe9de2bd1 | ||
|
|
342aacd060 | ||
|
|
a95d621bdb | ||
|
|
68a8f01b32 | ||
|
|
ca07f1b904 | ||
|
|
1aa76be031 | ||
|
|
38be68455c | ||
|
|
2f3cfd5387 | ||
|
|
d3a99c0ff4 | ||
|
|
b4d5a40ca4 | ||
|
|
356e71ad22 | ||
|
|
d6b58f6eed | ||
|
|
785f448baf | ||
|
|
194987f66a | ||
|
|
bbff63bd7d | ||
|
|
f654b71b31 | ||
|
|
b85d1d1a48 | ||
|
|
d669d2862e | ||
|
|
e6e71716c8 | ||
|
|
5022148fd9 | ||
|
|
f069edc026 | ||
|
|
65cf7b30c3 | ||
|
|
5110b42f0a | ||
|
|
92475f7331 | ||
|
|
12ad38fc78 | ||
|
|
dbf6e24d68 | ||
|
|
19d42a41dd | ||
|
|
573fbdb6d8 | ||
|
|
52a7024e82 | ||
|
|
600ae2e36f | ||
|
|
d7b744a01a | ||
|
|
a3d4f8ca6a | ||
|
|
18ad4fe9de | ||
|
|
efb4cc6a4d | ||
|
|
09dd2ad329 | ||
|
|
5f079c46d6 | ||
|
|
ec899e3ee4 | ||
|
|
f7813e4b1b | ||
|
|
f5d00c0a2a | ||
|
|
9b9348b341 | ||
|
|
b5266cda66 | ||
|
|
d6d716c5ab | ||
|
|
de5a611718 | ||
|
|
d595acbde3 | ||
|
|
638ce9ae6c | ||
|
|
bd2c67d725 | ||
|
|
f8ae5418c9 | ||
|
|
5e1959dec3 | ||
|
|
b7a276dd05 | ||
|
|
b2b3db616a | ||
|
|
17e1a8828c | ||
|
|
1674220340 | ||
|
|
ab76d51b84 | ||
|
|
4863e623ae | ||
|
|
ac88159d56 |
No files matched your search
@@ -0,0 +1,109 @@
|
||||
name: Auto Merge Dev to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize] # No longer need 'closed' trigger
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
# Job to enable Auto-Merge for the PR
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for open PRs from dev to main
|
||||
if: github.head_ref == 'dev' && github.base_ref == 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Required for git history
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Get PR number
|
||||
id: get-pr
|
||||
run: |
|
||||
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for conflicts
|
||||
id: check-conflicts
|
||||
run: |
|
||||
# Ensure local branches are up-to-date
|
||||
git fetch origin main
|
||||
git fetch origin dev
|
||||
|
||||
# Use merge-base to find the common ancestor and check for conflicts
|
||||
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "❌ Merge conflicts detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ No merge conflicts"
|
||||
fi
|
||||
|
||||
- name: Add conflict comment
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "❌ **Merge Conflict Detection**
|
||||
|
||||
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
|
||||
|
||||
Steps to resolve conflicts:
|
||||
1. \`git checkout dev\`
|
||||
2. \`git merge main\`
|
||||
3. Resolve conflicts
|
||||
4. \`git commit\`
|
||||
5. \`git push origin dev\`"
|
||||
exit 1 # Exit to prevent further steps
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Enable auto-merge using a merge commit
|
||||
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
|
||||
# Add success comment
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "✅ **Auto-merge Enabled**
|
||||
|
||||
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
|
||||
|
||||
Merge method: Merge commit
|
||||
Branch retention: dev branch will be preserved"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
|
||||
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Build Binaries on Main Push and PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- 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/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
# Append the appropriate executable extension based on the OS
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Cleanup Old Packages
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
#schedule:
|
||||
# - cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
# 会清理掉正常的镜像
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
@@ -20,40 +21,41 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
|
||||
// 分页获取所有包版本
|
||||
const allVersions = [];
|
||||
let page = 1;
|
||||
let fetched;
|
||||
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
|
||||
do {
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
page,
|
||||
per_page: 100
|
||||
});
|
||||
fetched = packages.length;
|
||||
allVersions.push(...packages);
|
||||
page++;
|
||||
} while (fetched === 100);
|
||||
|
||||
// 筛选仅有 SHA/sha256 标签或无标签的版本
|
||||
const shaOrNoTagVersions = allVersions.filter(pkg => {
|
||||
const tags = pkg.metadata.container.tags;
|
||||
return (
|
||||
(tags.length === 1 && (
|
||||
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
|
||||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
|
||||
)) ||
|
||||
tags.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
// Keep packages with 'dev' or 'latest' tags, plus the 10 most recent non-dev, non-latest versions
|
||||
const keepVersions = [
|
||||
// Keep packages with 'dev' tag
|
||||
...packages.filter(pkg => pkg.metadata.container.tags.includes('dev')),
|
||||
// Keep packages with 'latest' tag, excluding those already included with 'dev'
|
||||
...packages.filter(
|
||||
pkg =>
|
||||
pkg.metadata.container.tags.includes('latest') &&
|
||||
!pkg.metadata.container.tags.includes('dev')
|
||||
),
|
||||
// Keep the 10 most recent non-dev, non-latest packages
|
||||
...packages
|
||||
.filter(
|
||||
pkg =>
|
||||
!pkg.metadata.container.tags.includes('dev') &&
|
||||
!pkg.metadata.container.tags.includes('latest')
|
||||
)
|
||||
.slice(0, 10),
|
||||
];
|
||||
|
||||
// Identify versions to delete (not in keepVersions)
|
||||
const deleteVersions = packages.filter(pkg => !keepVersions.includes(pkg));
|
||||
|
||||
// Delete the versions
|
||||
for (const version of deleteVersions) {
|
||||
// 删除这些版本
|
||||
for (const version of shaOrNoTagVersions) {
|
||||
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
|
||||
package_name: packageName,
|
||||
package_version_id: version.id
|
||||
});
|
||||
console.log(`Deleted package version ${version.id}`);
|
||||
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -12,13 +12,13 @@ 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 public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
@@ -26,8 +26,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -69,7 +76,7 @@ jobs:
|
||||
- name: Add host to known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
|
||||
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Docker Build and Publish
|
||||
name: Publish Docker Image on Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
build-and-push-multi-arch:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,6 +21,61 @@ jobs:
|
||||
- name: Checkout repository
|
||||
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/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- 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.ref_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -42,8 +97,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-multi-arch:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- 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: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Attach Binaries to Release
|
||||
name: Attach Release Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -19,7 +19,43 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64]
|
||||
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: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -28,7 +64,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '23'
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
@@ -37,69 +73,54 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
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
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}.exe
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Define the binary name for upload
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}.exe
|
||||
fi
|
||||
# Upload the binary as a release asset using GitHub CLI
|
||||
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
VERSION_HASH=${{ github.sha }}
|
||||
@@ -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,6 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
+22
-28
@@ -1,34 +1,29 @@
|
||||
FROM node:23-alpine AS frontend
|
||||
WORKDIR /web
|
||||
RUN apk add --no-cache git
|
||||
RUN git clone https://github.com/komari-monitor/komari-web .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.24 AS builder
|
||||
WORKDIR /app
|
||||
# RUN apt-get update && apt-get install -y musl-tools
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /web/dist ./public/dist
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ARG VERSION=unknown
|
||||
ARG VERSION_HASH=unknown
|
||||
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
|
||||
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
|
||||
|
||||
# FROM scratch
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/komari .
|
||||
# Docker buildx 会在构建时自动填充这些变量
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
RUN set -eux; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) cloudflared_arch="amd64" ;; \
|
||||
386) cloudflared_arch="386" ;; \
|
||||
arm64) cloudflared_arch="arm64" ;; \
|
||||
arm) cloudflared_arch="arm" ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflared_arch}" -o /usr/local/bin/cloudflared; \
|
||||
chmod +x /usr/local/bin/cloudflared
|
||||
|
||||
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
|
||||
@@ -40,5 +35,4 @@ 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,27 +1,51 @@
|
||||
|
||||
# Komari
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||

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

|
||||
|
||||
## 特性
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.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+(手工构建)
|
||||
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
### Docker 部署
|
||||
1. 创建数据目录:
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 0. One-click Deployment with Cloud Hosting
|
||||
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel App Store
|
||||
|
||||
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
|
||||
|
||||
### 1. Use the One-click Install Script
|
||||
|
||||
Suitable for distributions using systemd (Ubuntu, Debian...).
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker Deployment
|
||||
|
||||
1. Create a data directory:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. 运行 Docker 容器:
|
||||
2. Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
@@ -29,103 +53,90 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. 查看默认账号和密码:
|
||||
3. View the default username and password:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
|
||||
4. Access `http://<your_server_ip>:25774` in your browser.
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
### 二进制文件部署
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
### 3. Binary File Deployment
|
||||
|
||||
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
|
||||
2. Run Komari:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
|
||||
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
|
||||
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
|
||||
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
|
||||
|
||||
### Manual Build
|
||||
|
||||
### 手工构建
|
||||
1. 构建前端静态文件:
|
||||
#### Dependencies
|
||||
|
||||
- Go 1.18+ and Node.js 20+ (for manual build)
|
||||
|
||||
1. Build the frontend static files:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. 构建后端:
|
||||
2. Build the backend:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
Copy the static files generated in step 1 to the `/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
3. Run:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
默认监听 `25774` 端口,访问 `http://localhost:25774`。
|
||||
The default listening port is `25774`. Access `http://localhost:25774`.
|
||||
|
||||
## 前端开发指南
|
||||
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
|
||||
## Frontend Development Guide
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
这个坑晚点再填吧 (o゜▽゜)o☆
|
||||
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
## Client Agent Development Guide
|
||||
|
||||
## 鸣谢
|
||||
- 感谢 [Alice Networks LTD](https://app.alice.ws/) 提供的免费服务器
|
||||
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 引用
|
||||
- [gorm.io](https://gorm.io/)
|
||||
- [spf13/cobra](https://github.com/spf13/cobra)
|
||||
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
|
||||
- [gorilla/websocket](https://github.com/gorilla/websocket)
|
||||
- [google/uuid](https://github.com/google/uuid)
|
||||
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
|
||||
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
|
||||
- [creack/pty](https://github.com/creack/pty)
|
||||
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
|
||||
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
|
||||
## Contributing
|
||||
|
||||
## 许可证
|
||||
[MIT License](LICENSE)
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
## 截图
|
||||
## Acknowledgements
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
### 破碎工坊云
|
||||
|
||||
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
|
||||
|
||||
## 碎碎念
|
||||
### DreamCloud
|
||||
|
||||
### 我为什么做Komari?
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
|
||||
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
|
||||
### 🚀 Sponsored by SharonNetworks
|
||||
|
||||
> @古xx斯
|
||||
> 哪吒?
|
||||
> > 数据至少保存15天
|
||||
[](https://sharon.io)
|
||||
|
||||
> @sxxu
|
||||
> 怕不是做梦的预算
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
> @Vxxn
|
||||
> prometheus加grafana
|
||||
> > 部署太麻烦
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
|
||||
|
||||
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
|
||||
### The open source software community
|
||||
|
||||
All the developers who submitted PRs and created themes
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleClient = "client"
|
||||
RoleGuest = "guest"
|
||||
)
|
||||
|
||||
// IdentityMiddleware 统一身份识别中间件,在路由栈最外层运行。
|
||||
// 负责识别当前请求者身份(Admin / Client / Guest),并写入 Context。
|
||||
func IdentityMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1. API Key 认证
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("role", RoleAdmin)
|
||||
c.Set("api_key", apiKey[7:])
|
||||
c.Set("uuid", "00000000-0000-0000-0000-000000000000") // API Key
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Session 认证
|
||||
session, err := c.Cookie("session_token")
|
||||
if err == nil && session != "" {
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err == nil {
|
||||
c.Set("role", RoleAdmin)
|
||||
c.Set("session", session)
|
||||
c.Set("uuid", uuid)
|
||||
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Client Token 认证
|
||||
token := extractClientToken(c)
|
||||
if token != "" {
|
||||
uuid, err := checkTokenAndGetUUID(token)
|
||||
if err == nil && uuid != "" {
|
||||
c.Set("role", RoleClient)
|
||||
c.Set("client_uuid", uuid)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 未识别身份,标记为访客
|
||||
c.Set("role", RoleGuest)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 声明式权限校验中间件,仅允许指定角色通过。
|
||||
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
current := GetRole(c)
|
||||
for _, role := range allowedRoles {
|
||||
if current == role {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRole 获取当前请求的角色
|
||||
func GetRole(c *gin.Context) string {
|
||||
role, exists := c.Get("role")
|
||||
if !exists {
|
||||
return RoleGuest
|
||||
}
|
||||
if s, ok := role.(string); ok {
|
||||
return s
|
||||
}
|
||||
return RoleGuest
|
||||
}
|
||||
|
||||
// --- 私有站点访问控制 ---
|
||||
|
||||
var publicPaths = []string{
|
||||
"/ping",
|
||||
"/api/public",
|
||||
"/api/login",
|
||||
"/api/me",
|
||||
"/api/oauth",
|
||||
"/api/oauth_callback",
|
||||
"/api/version",
|
||||
"/api/recent",
|
||||
"/api/admin", // 由 RequireRole 处理
|
||||
"/api/clients/", // 由 RequireRole 处理
|
||||
}
|
||||
|
||||
// PrivateSiteMiddleware 私有站点访问控制。
|
||||
// 依赖 IdentityMiddleware 已设置的 role,对未认证的访客在私有站点模式下进行拦截。
|
||||
func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 已认证用户直接放行
|
||||
if GetRole(c) != RoleGuest {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 公开路径直接放行
|
||||
for _, p := range publicPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 非 API 路径直接放行(静态资源等)
|
||||
if !strings.HasPrefix(path, "/api") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 非私有站点直接放行
|
||||
privateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !privateSite {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 临时访问许可
|
||||
if hasTempAccess(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
func hasTempAccess(c *gin.Context) bool {
|
||||
tempKey, err := c.Cookie("temp_key")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expireAt, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowKey, err := config.GetAs[string]("tempory_share_token", "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if allowKey == "" || tempKey != allowKey {
|
||||
return false
|
||||
}
|
||||
return expireAt >= time.Now().Unix()
|
||||
}
|
||||
|
||||
func extractClientToken(c *gin.Context) string {
|
||||
token := c.Query("token")
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
if c.Request.Method != http.MethodGet {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
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 != "" {
|
||||
return str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkTokenAndGetUUID(token string) (string, error) {
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+apiKeyConfig
|
||||
}
|
||||
+69
-31
@@ -1,13 +1,15 @@
|
||||
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"
|
||||
@@ -15,39 +17,81 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Records map[string][]common.Report = make(map[string][]common.Report)
|
||||
Records = cache.New(1*time.Minute, 1*time.Minute)
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
}
|
||||
|
||||
var TerminalSessionsMutex = &sync.Mutex{}
|
||||
var TerminalSessions = make(map[string]*TerminalSession)
|
||||
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute * 1).Unix()
|
||||
record := []models.Record{}
|
||||
// 遍历所有的客户端记录
|
||||
for uuid, reports := range Records {
|
||||
// 删除一分钟前的记录
|
||||
filtered := reports[:0]
|
||||
lastMinute := time.Now().Add(-time.Minute).Unix()
|
||||
var records []models.Record
|
||||
var gpuRecords []models.GPURecord
|
||||
|
||||
// 遍历所有客户端记录
|
||||
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[uuid] = filtered
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered)
|
||||
|
||||
record = append(record, r)
|
||||
// 更新缓存
|
||||
Records.Set(uuid, filtered, cache.DefaultExpiration)
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Record{}).Where("client = ?", uuid).Create(record).Error
|
||||
if err != nil {
|
||||
// 计算平均报告并添加到记录列表
|
||||
if len(filtered) > 0 {
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
|
||||
records = append(records, r)
|
||||
|
||||
// 使用与其他数据相同的聚合逻辑处理GPU数据
|
||||
gpuAggregated := utils.AverageGPUReports(uuid, time.Now(), filtered, 0.3)
|
||||
gpuRecords = append(gpuRecords, gpuAggregated...)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入数据库前去重(client与time共同构成唯一键)
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
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)
|
||||
}
|
||||
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
|
||||
log.Printf("Failed to save records to database: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入GPU记录
|
||||
if len(gpuRecords) > 0 {
|
||||
// GPU记录也需要去重,防止重复插入
|
||||
gpuUnique := make(map[string]models.GPURecord)
|
||||
for _, rec := range gpuRecords {
|
||||
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
|
||||
gpuUnique[key] = rec
|
||||
}
|
||||
var gpuDeduped []models.GPURecord
|
||||
for _, rec := range gpuUnique {
|
||||
gpuDeduped = append(gpuDeduped, rec)
|
||||
}
|
||||
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
|
||||
log.Printf("Failed to save GPU records to database: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -80,9 +124,3 @@ func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
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
|
||||
}
|
||||
+8
-5
@@ -4,9 +4,10 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func AddClient(c *gin.Context) {
|
||||
@@ -28,7 +29,7 @@ func AddClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ func EditClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
@@ -65,8 +66,10 @@ func RemoveClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
ws.DeleteLatestReport(uuid)
|
||||
}
|
||||
|
||||
func ClearRecord(c *gin.Context) {
|
||||
@@ -78,7 +81,7 @@ func ClearRecord(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
@@ -23,6 +23,6 @@ func OrderWeight(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
|
||||
"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")
|
||||
auditlog.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")
|
||||
auditlog.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")
|
||||
auditlog.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")
|
||||
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
)
|
||||
|
||||
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
|
||||
|
||||
func GetCloudflaredStatus(c *gin.Context) {
|
||||
api.RespondSuccess(c, cloudflared.Status())
|
||||
}
|
||||
|
||||
func StartCloudflared(c *gin.Context) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if token != "" {
|
||||
if err := cloudflared.SaveToken(token); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to save Cloudflare Tunnel token: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := cloudflared.Start(token); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if uuid, ok := c.Get("uuid"); ok {
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "started cloudflared tunnel", "warn")
|
||||
}
|
||||
api.RespondSuccess(c, cloudflared.Status())
|
||||
}
|
||||
|
||||
func StopCloudflared(c *gin.Context) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
ConfirmText string `json:"confirm_text"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err.Error() != "EOF" {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
disablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
|
||||
if !disablePasswordLogin {
|
||||
rawUUID, exists := c.Get("uuid")
|
||||
if !exists {
|
||||
api.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := accounts.GetUserByUUID(rawUUID.(string))
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusUnauthorized, "Failed to verify current user")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.CurrentPassword) == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "Current password is required")
|
||||
return
|
||||
}
|
||||
if _, ok := accounts.CheckPassword(user.Username, req.CurrentPassword); !ok {
|
||||
api.RespondError(c, http.StatusUnauthorized, "Current password is incorrect")
|
||||
return
|
||||
}
|
||||
} else if strings.TrimSpace(req.ConfirmText) != cloudflaredStopConfirmText {
|
||||
api.RespondError(c, http.StatusBadRequest, "Type STOP CLOUDFLARED to confirm stopping cloudflared")
|
||||
return
|
||||
}
|
||||
|
||||
if err := cloudflared.Stop(); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to stop cloudflared: "+err.Error())
|
||||
return
|
||||
}
|
||||
if uuid, ok := c.Get("uuid"); ok {
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "stopped cloudflared tunnel", "warn")
|
||||
}
|
||||
api.RespondSuccess(c, cloudflared.Status())
|
||||
}
|
||||
|
||||
func RemoveCloudflaredToken(c *gin.Context) {
|
||||
if err := cloudflared.RemoveToken(); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Failed to remove Cloudflare Tunnel token: "+err.Error())
|
||||
return
|
||||
}
|
||||
if uuid, ok := c.Get("uuid"); ok {
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "removed cloudflared tunnel token", "warn")
|
||||
}
|
||||
api.RespondSuccess(c, cloudflared.Status())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
)
|
||||
|
||||
// copyFile 复制单个文件到目标路径(会确保父目录存在)
|
||||
func copyFile(srcPath, destPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %v", err)
|
||||
}
|
||||
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file: %v", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
if _, err = io.Copy(dest, src); err != nil {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDataToTempExcludingDB 将 ./data 下除了 .db/.db-wal/.db-shm 之外的所有文件复制到临时目录
|
||||
func copyDataToTempExcludingDB(tempDir string) error {
|
||||
dataRoot := "./data"
|
||||
|
||||
// 如果 data 目录不存在,视为无文件可复制
|
||||
if stat, err := os.Stat(dataRoot); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat data dir: %v", err)
|
||||
} else if !stat.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dataRoot)
|
||||
}
|
||||
|
||||
return filepath.Walk(dataRoot, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dataRoot, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 跳过数据库相关文件
|
||||
name := info.Name()
|
||||
if strings.HasSuffix(strings.ToLower(name), ".db") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".db-wal") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".db-shm") {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := filepath.Join(tempDir, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
return copyFile(p, dst)
|
||||
})
|
||||
}
|
||||
|
||||
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径
|
||||
func backupSQLiteTo(destDBPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for db: %v", err)
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database connection: %v", err)
|
||||
}
|
||||
|
||||
safePath := strings.ReplaceAll(destDBPath, "'", "''")
|
||||
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
|
||||
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
|
||||
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
|
||||
func DownloadBackup(c *gin.Context) {
|
||||
// 1) 创建临时目录
|
||||
tempDir, err := os.MkdirTemp("", "komari-backup-*")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
|
||||
if err := copyDataToTempExcludingDB(tempDir); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 处理数据库备份 -> 临时目录/komari.db
|
||||
destDB := filepath.Join(tempDir, "komari.db")
|
||||
dbFilePath := flags.DatabaseFile
|
||||
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
if err := backupSQLiteTo(destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
|
||||
return
|
||||
}
|
||||
} else if dbFilePath != "" {
|
||||
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
|
||||
if _, err := os.Stat(dbFilePath); err == nil {
|
||||
if err := copyFile(dbFilePath, destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 开始写出 ZIP(以临时目录为根)
|
||||
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
|
||||
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(tempDir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(tempDir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// zip 内路径统一正斜杠
|
||||
zipPath := filepath.ToSlash(rel)
|
||||
if info.IsDir() {
|
||||
_, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath + "/",
|
||||
Method: zip.Deflate,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath,
|
||||
Method: zip.Deflate,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 5) 追加备份标记文件(放在 zip 根目录)
|
||||
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
|
||||
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "komari-backup-markup",
|
||||
Method: zip.Deflate,
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
+35
-15
@@ -2,11 +2,13 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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/auditlog"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
@@ -21,16 +23,25 @@ func Exec(c *gin.Context) {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
var offlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for uuid := range ws.ConnectedClients {
|
||||
if contain(req.Clients, uuid) {
|
||||
// for uuid := range ws.GetConnectedClients() {
|
||||
// if contain(req.Clients, uuid) {
|
||||
// onlineClients = append(onlineClients, uuid)
|
||||
// }
|
||||
// // else {
|
||||
// // api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
// // return
|
||||
// // }
|
||||
// }
|
||||
for _, uuid := range req.Clients {
|
||||
if client := ws.GetConnectedClients()[uuid]; client != nil {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
return
|
||||
offlineClients = append(offlineClients, uuid)
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
@@ -38,7 +49,7 @@ func Exec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -53,26 +64,35 @@ func Exec(c *gin.Context) {
|
||||
send.TaskId = taskId
|
||||
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.ConnectedClients[uuid]
|
||||
client := ws.GetConnectedClients()[uuid]
|
||||
if client != nil {
|
||||
client.WriteMessage(websocket.TextMessage, payload)
|
||||
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
api.RespondError(c, 400, "Client connection is broke: "+uuid)
|
||||
return
|
||||
}
|
||||
} 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")
|
||||
auditlog.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
|
||||
if len(offlineClients) > 0 {
|
||||
for _, uuid := range offlineClients {
|
||||
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// func contain(clients []string, uuid string) bool {
|
||||
// for _, client := range clients {
|
||||
// if client == uuid {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
@@ -0,0 +1,64 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
func GetMessageSenderProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetMessageSenderConfigByName(provider)
|
||||
if err != nil {
|
||||
api.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置项模板
|
||||
providers := factory.GetSenderConfigs()
|
||||
if len(providers) == 0 {
|
||||
api.RespondError(c, 404, "No message sender providers found")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetMessageSenderProvider(c *gin.Context) {
|
||||
var senderConfig models.MessageSenderProvider
|
||||
if err := c.ShouldBindJSON(&senderConfig); err != nil {
|
||||
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if senderConfig.Name == "" {
|
||||
api.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(senderConfig.Name)
|
||||
if !exists {
|
||||
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
|
||||
return
|
||||
}
|
||||
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
|
||||
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
|
||||
|
||||
// 正在使用,重载
|
||||
if NotificationMethod == senderConfig.Name {
|
||||
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,7 +3,12 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
)
|
||||
|
||||
func BindingExternalAccount(c *gin.Context) {
|
||||
@@ -33,3 +38,56 @@ func UnbindExternalAccount(c *gin.Context) {
|
||||
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func GetOidcProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetOidcConfigByName(provider)
|
||||
if err != nil {
|
||||
api.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置
|
||||
providers := factory.GetProviderConfigs()
|
||||
if len(providers) == 0 {
|
||||
api.RespondError(c, 404, "No OIDC providers found")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetOidcProvider(c *gin.Context) {
|
||||
var oidcConfig models.OidcProvider
|
||||
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
|
||||
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if oidcConfig.Name == "" {
|
||||
api.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(oidcConfig.Name)
|
||||
if !exists {
|
||||
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
|
||||
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
OAuthProvider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
|
||||
// 正在使用,重载
|
||||
if OAuthProvider == oidcConfig.Name {
|
||||
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// AddPingTask 处理新增延迟监测任务请求。
|
||||
// POST body: clients []string, default_on bool, target, task_type string, interval int
|
||||
func AddPingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients"`
|
||||
DefaultOn bool `json:"default_on"`
|
||||
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 !req.DefaultOn && len(req.Clients) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "clients is required when default_on is false")
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := tasks.AddPingTask(req.Clients, req.DefaultOn, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// EditPingTask 处理延迟监测任务编辑请求。
|
||||
// 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
|
||||
}
|
||||
for _, task := range req.Tasks {
|
||||
// 编辑时只拦截空任务对象,允许把任务临时保存为未绑定服务器状态。
|
||||
if task == 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)
|
||||
}
|
||||
|
||||
func OrderPingTask(c *gin.Context) {
|
||||
var req map[string]int
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
order := make(map[uint]int, len(req))
|
||||
for idStr, weight := range req {
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid task id: "+idStr)
|
||||
return
|
||||
}
|
||||
order[uint(id)] = weight
|
||||
}
|
||||
|
||||
if err := tasks.UpdatePingTaskOrder(order); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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/komari-monitor/komari/database/auditlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -33,7 +33,7 @@ func DeleteSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,6 @@ func DeleteAllSession(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
+15
-32
@@ -1,32 +1,21 @@
|
||||
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/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"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()
|
||||
cst, err := config.GetAll()
|
||||
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.RespondError(c, 500, "Failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
@@ -39,8 +28,7 @@ func EditSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
if err := config.SetMany(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -48,24 +36,19 @@ func EditSettings(c *gin.Context) {
|
||||
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")
|
||||
auditlog.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")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
+12
-9
@@ -5,16 +5,19 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/telegram"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
func TestTelegram(c *gin.Context) {
|
||||
message := "Komari test message."
|
||||
err := telegram.SendTextMessage(message)
|
||||
func TestSendMessage(c *gin.Context) {
|
||||
err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: "Test",
|
||||
Message: "This is a test message from Komari.",
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
|
||||
api.RespondError(c, 500, "Failed to send message: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
@@ -29,16 +32,16 @@ func TestGeoIp(c *gin.Context) {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := config.Get()
|
||||
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
if !cfg {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
|
||||
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
)
|
||||
|
||||
// UploadTheme 上传主题
|
||||
func UploadTheme(c *gin.Context) {
|
||||
// 读取上传的文件内容
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil || len(data) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// 检查文件扩展名(这里假定上传的就是zip)
|
||||
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
|
||||
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 解压ZIP文件并验证
|
||||
themeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
|
||||
}
|
||||
|
||||
// ListThemes 列出所有主题
|
||||
func ListThemes(c *gin.Context) {
|
||||
dataDir := "./data/theme"
|
||||
|
||||
// 确保主题目录存在
|
||||
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
|
||||
api.RespondSuccess(c, []models.Theme{})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var themes []models.Theme
|
||||
defaultTheme, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err == nil {
|
||||
dt := models.Theme{}
|
||||
err := json.Unmarshal(defaultTheme, &dt)
|
||||
if err == nil {
|
||||
themes = append(themes, dt)
|
||||
}
|
||||
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
|
||||
if themeInfo, err := loadThemeConfig(themeConfigPath); err == nil {
|
||||
themes = append(themes, themeInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, themes)
|
||||
}
|
||||
|
||||
// DeleteTheme 删除主题
|
||||
func DeleteTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
Short string `json:"short" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Short == "default" {
|
||||
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
themeDir := filepath.Join("./data/theme", req.Short)
|
||||
|
||||
// 检查主题是否存在
|
||||
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除主题目录
|
||||
if err := os.RemoveAll(themeDir); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题删除成功", nil)
|
||||
}
|
||||
|
||||
// SetTheme 设置主题
|
||||
func SetTheme(c *gin.Context) {
|
||||
themeName := c.Query("theme")
|
||||
if themeName == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不是default主题,检查主题是否存在
|
||||
if themeName != "default" {
|
||||
themeDir := filepath.Join("./data/theme", themeName)
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Set("theme", themeName); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
|
||||
}
|
||||
|
||||
// extractAndValidateTheme 解压并验证主题
|
||||
func extractAndValidateTheme(zipPath string) (models.Theme, error) {
|
||||
var themeInfo models.Theme
|
||||
|
||||
// 打开ZIP文件
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// 查找komari-theme.json文件
|
||||
var themeConfigFile *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.Name == "komari-theme.json" {
|
||||
themeConfigFile = f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if themeConfigFile == nil {
|
||||
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在")
|
||||
}
|
||||
|
||||
// 读取主题配置
|
||||
rc, err := themeConfigFile.Open()
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
configData, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configData, &themeInfo); err != nil {
|
||||
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if themeInfo.Name == "" || themeInfo.Short == "" {
|
||||
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
|
||||
}
|
||||
|
||||
// 验证Short字段格式(只允许字母、数字、下划线、连字符)
|
||||
if !isValidThemeShort(themeInfo.Short) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
// 创建主题目录
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
|
||||
// 如果目录已存在,先删除
|
||||
if _, err := os.Stat(themeDir); err == nil {
|
||||
if err := os.RemoveAll(themeDir); err != nil {
|
||||
return themeInfo, fmt.Errorf("删除原有主题失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(themeDir, 0755); err != nil {
|
||||
return themeInfo, fmt.Errorf("创建主题目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 解压文件到主题目录
|
||||
for _, f := range r.File {
|
||||
path := filepath.Join(themeDir, f.Name)
|
||||
|
||||
// 安全检查,防止路径遍历攻击
|
||||
if !strings.HasPrefix(path, filepath.Clean(themeDir)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, f.FileInfo().Mode())
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return themeInfo, fmt.Errorf("创建目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("打开压缩文件失败: %v", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.FileInfo().Mode())
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return themeInfo, fmt.Errorf("创建文件失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("解压文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return themeInfo, nil
|
||||
}
|
||||
|
||||
// loadThemeConfig 加载主题配置
|
||||
func loadThemeConfig(configPath string) (models.Theme, error) {
|
||||
var themeInfo models.Theme
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &themeInfo); err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
return themeInfo, nil
|
||||
}
|
||||
|
||||
// isValidThemeShort 验证主题short字段格式
|
||||
func isValidThemeShort(short string) bool {
|
||||
if short == "" || short == "default" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range short {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadThemeFromURL 从URL下载主题文件
|
||||
// isPrivateIP checks if the resolved IP addresses are private/internal
|
||||
func isPrivateIP(host string) bool {
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return true // fail closed
|
||||
}
|
||||
for _, ipStr := range ips {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func downloadThemeFromURL(rawURL string) ([]byte, error) {
|
||||
// SSRF protection: block requests to private/internal IPs
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("only http and https schemes are allowed")
|
||||
}
|
||||
if isPrivateIP(parsedURL.Hostname()) {
|
||||
return nil, fmt.Errorf("requests to private/internal addresses are not allowed")
|
||||
}
|
||||
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载主题文件失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("下载的主题文件为空")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
|
||||
// 该函数通过GitHub API获取指定仓库最新release的资源下载链接
|
||||
// 参考API: https://api.github.com/repos/{owner}/{repo}/releases/latest
|
||||
// 参数:
|
||||
// - owner: GitHub仓库所有者
|
||||
// - repo: GitHub仓库名称
|
||||
//
|
||||
// 返回:
|
||||
// - 最新release的第一个资源的下载链接
|
||||
// - 错误信息(如果有)
|
||||
func getGitHubReleaseDownloadURL(owner, repo string) (string, error) {
|
||||
if owner == "" || repo == "" {
|
||||
return "", errors.New("GitHub仓库所有者和仓库名称不能为空")
|
||||
}
|
||||
|
||||
// 构建GitHub API URL
|
||||
// 使用GitHub API获取最新release信息
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析JSON响应
|
||||
// GitHub API返回的JSON包含assets数组,每个asset包含browser_download_url字段
|
||||
var releaseInfo struct {
|
||||
Assets []struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否有可下载的资源
|
||||
if len(releaseInfo.Assets) == 0 {
|
||||
return "", errors.New("GitHub release中没有可下载的资源")
|
||||
}
|
||||
|
||||
// 返回第一个资源的下载链接
|
||||
// 相当于shell命令: curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url"
|
||||
return releaseInfo.Assets[0].BrowserDownloadURL, nil
|
||||
}
|
||||
|
||||
// isGitHubRepoURL 检查URL是否是GitHub仓库地址
|
||||
// 支持的格式:
|
||||
// - https://github.com/owner/repo
|
||||
// - https://github.com/owner/repo.git
|
||||
// - https://www.github.com/owner/repo
|
||||
// - http://github.com/owner/repo
|
||||
// 返回:
|
||||
// - 是否是GitHub仓库URL
|
||||
// - 仓库所有者
|
||||
// - 仓库名称
|
||||
func isGitHubRepoURL(urlStr string) (bool, string, string) {
|
||||
if urlStr == "" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查URL是否包含github.com
|
||||
if !strings.Contains(strings.ToLower(urlStr), "github.com") {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析URL
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查主机名是否是github.com或www.github.com
|
||||
hostname := strings.ToLower(parsedURL.Host)
|
||||
if hostname != "github.com" && hostname != "www.github.com" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析路径部分,提取owner和repo
|
||||
// 路径格式应该是 /owner/repo 或 /owner/repo.git
|
||||
path := strings.TrimPrefix(parsedURL.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
owner := parts[0]
|
||||
repo := parts[1]
|
||||
|
||||
// 如果repo以.git结尾,去掉这个后缀
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
|
||||
return true, owner, repo
|
||||
}
|
||||
|
||||
// UpdateTheme 更新主题
|
||||
// 支持四种更新方式:
|
||||
// 1. 使用主题原有URL下载更新
|
||||
// 2. 提供新的直接下载URL进行更新
|
||||
// 3. 提供GitHub仓库信息,从最新release下载更新
|
||||
// 4. 如果主题URL是GitHub仓库地址,自动获取最新release
|
||||
func UpdateTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
Short string `json:"short" binding:"required"` // 主题短名称
|
||||
URL string `json:"url"` // 新的URL地址(可选)
|
||||
GitOwner string `json:"git_owner"` // GitHub仓库所有者(可选)
|
||||
GitRepo string `json:"git_repo"` // GitHub仓库名称(可选)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主题是否存在
|
||||
themeDir := filepath.Join("./data/theme", req.Short)
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 加载现有主题配置
|
||||
themeInfo, err := loadThemeConfig(themeConfigPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 方式1和方式4: 尝试从原始URL下载主题
|
||||
// 如果原始URL是GitHub仓库地址,则自动获取最新release
|
||||
var themeData []byte
|
||||
// 不保存下载链接,更新后由主题覆盖
|
||||
//var downloadURL string
|
||||
// var err2 error
|
||||
|
||||
if themeInfo.URL != "" {
|
||||
// 检查原始URL是否是GitHub仓库地址
|
||||
// 例如: https://github.com/owner/repo
|
||||
isGitHub, owner, repo := isGitHubRepoURL(themeInfo.URL)
|
||||
if isGitHub {
|
||||
// 方式4: 如果原始URL是GitHub仓库地址,自动获取最新release
|
||||
// 这是本次需求的核心功能:当主题文件中现有的url地址如果是github仓库的路径,则直接引用该url地址去下载最新的release
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err == nil {
|
||||
// 使用获取到的GitHub release下载链接下载主题
|
||||
themeData, _ = downloadThemeFromURL(gitHubURL)
|
||||
//if err2 == nil {
|
||||
// 注意:这里我们保存的是release的下载链接,而不是GitHub仓库地址
|
||||
// 这样做是为了在下载成功后,将这个具体的release下载链接保存到主题配置中
|
||||
// 但在下次更新时,我们仍然会检测到这是一个GitHub仓库,并获取最新的release
|
||||
// downloadURL = gitHubURL
|
||||
//}
|
||||
}
|
||||
} else {
|
||||
// 原始URL不是GitHub仓库地址,直接尝试下载(方式1)
|
||||
themeData, _ = downloadThemeFromURL(themeInfo.URL)
|
||||
//if err2 == nil {
|
||||
// downloadURL = themeInfo.URL
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原始URL下载失败,尝试其他方式下载
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
// 方式3: 如果提供了GitHub仓库信息,尝试从GitHub最新release下载
|
||||
// 这种方式允许用户只需提供owner和repo信息,系统会自动获取最新release的下载链接
|
||||
if req.GitOwner != "" && req.GitRepo != "" {
|
||||
// 从GitHub API获取下载链接
|
||||
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存下载链接,稍后更新到主题配置中
|
||||
// downloadURL = gitHubURL
|
||||
} else if req.URL != "" {
|
||||
// 方式2: 如果提供了新URL,尝试从新URL下载
|
||||
// 检查新URL是否是GitHub仓库地址
|
||||
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
|
||||
if isGitHub {
|
||||
// 如果新URL是GitHub仓库地址,获取最新release
|
||||
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
|
||||
// 这是一个重要的设计决策:我们保存的是GitHub仓库URL,而不是具体的release下载链接
|
||||
// 这样在下次更新时,系统会再次检测到这是GitHub仓库,并自动获取最新的release
|
||||
// downloadURL = req.URL
|
||||
} else {
|
||||
// 新URL不是GitHub仓库地址,直接尝试下载
|
||||
themeData, err = downloadThemeFromURL(req.URL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// downloadURL = req.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有成功下载主题数据
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
|
||||
return
|
||||
}
|
||||
|
||||
// 到这里,我们已经成功获取了主题数据,可能是通过以下四种方式之一:
|
||||
// 1. 原始URL直接下载
|
||||
// 2. 原始URL是GitHub仓库,自动获取最新release下载
|
||||
// 3. 用户提供的新URL下载
|
||||
// 4. 用户提供的GitHub仓库信息,获取最新release下载
|
||||
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// 解压ZIP文件并验证
|
||||
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果下载URL与原始URL不同,更新主题配置中的URL
|
||||
// if downloadURL != themeInfo.URL {
|
||||
// updatedThemeInfo.URL = downloadURL
|
||||
|
||||
// // 更新主题配置文件
|
||||
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
|
||||
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
|
||||
// if err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
|
||||
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
|
||||
}
|
||||
|
||||
// peekThemeFromZip 仅从ZIP文件中读取komari-theme.json并解析主题信息
|
||||
// 不执行解压安装,用于preview模式
|
||||
func peekThemeFromZip(zipPath string) (models.Theme, error) {
|
||||
var themeInfo models.Theme
|
||||
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
var themeConfigFile *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.Name == "komari-theme.json" {
|
||||
themeConfigFile = f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if themeConfigFile == nil {
|
||||
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在,不是合法的主题包")
|
||||
}
|
||||
|
||||
rc, err := themeConfigFile.Open()
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
configData, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configData, &themeInfo); err != nil {
|
||||
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
|
||||
}
|
||||
|
||||
if themeInfo.Name == "" || themeInfo.Short == "" {
|
||||
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
|
||||
}
|
||||
|
||||
if !isValidThemeShort(themeInfo.Short) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
return themeInfo, nil
|
||||
}
|
||||
|
||||
// ImportTheme 导入远程主题
|
||||
// 支持preview查询参数:preview=true时仅返回主题信息,否则下载安装
|
||||
// 请求body: {"url": "https://..."}
|
||||
// URL支持GitHub仓库地址(自动取latest release)和直接ZIP下载链接
|
||||
func ImportTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析下载链接
|
||||
downloadURL := req.URL
|
||||
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
|
||||
if isGitHub {
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
downloadURL = gitHubURL
|
||||
}
|
||||
|
||||
// 下载主题ZIP
|
||||
themeData, err := downloadThemeFromURL(downloadURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到临时文件
|
||||
tempFile := filepath.Join(os.TempDir(), "import_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// preview模式:仅解析并返回主题信息
|
||||
preview := c.Query("preview")
|
||||
if preview == "true" {
|
||||
themeInfo, err := peekThemeFromZip(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已存在同名主题
|
||||
exists := false
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
if _, err := os.Stat(themeDir); err == nil {
|
||||
exists = true
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"theme": themeInfo,
|
||||
"exists": exists,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 安装模式:检查是否存在同名主题
|
||||
// 先peek一下获取short名称用于检测冲突
|
||||
themeInfo, err := peekThemeFromZip(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
overwritten := false
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
if _, err := os.Stat(themeDir); err == nil {
|
||||
overwritten = true
|
||||
}
|
||||
|
||||
// 解压安装
|
||||
installedTheme, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg := "主题导入成功"
|
||||
if overwritten {
|
||||
msg = "主题导入成功(已覆盖同名主题)"
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, msg, installedTheme)
|
||||
}
|
||||
|
||||
func UpdateThemeSettings(c *gin.Context) {
|
||||
theme := c.Query("theme")
|
||||
if theme == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]any
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
data, err := json.Marshal(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var themeCfg models.ThemeConfiguration
|
||||
db.Where("short = ?", theme).
|
||||
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
|
||||
FirstOrCreate(&themeCfg)
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
)
|
||||
|
||||
func UploadFavicon(c *gin.Context) {
|
||||
@@ -27,7 +27,7 @@ func UploadFavicon(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,6 @@ func DeleteFavicon(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -3,16 +3,16 @@ 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/database/auditlog"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.UpdateGeoIpDatabase(); err != nil {
|
||||
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")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
)
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
@@ -35,6 +35,6 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
)
|
||||
|
||||
// 只有一个备份恢复操作在进行
|
||||
var restoreMutex sync.Mutex
|
||||
|
||||
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
|
||||
func UploadBackup(c *gin.Context) {
|
||||
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
|
||||
if !restoreMutex.TryLock() {
|
||||
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
|
||||
return
|
||||
}
|
||||
defer restoreMutex.Unlock()
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("backup")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查文件是否为zip格式
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
||||
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保data目录存在
|
||||
if err := os.MkdirAll("./data", 0755); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
|
||||
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFilePath := tempFile.Name()
|
||||
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
|
||||
|
||||
// 将上传的文件内容复制到临时文件
|
||||
_, err = io.Copy(tempFile, file)
|
||||
if err != nil {
|
||||
tempFile.Close()
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFile.Close() // 关闭文件以便后续操作
|
||||
|
||||
// 基础校验:检查是否包含标记文件
|
||||
if zr, err := zip.OpenReader(tempFilePath); err == nil {
|
||||
hasMarkup := false
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "komari-backup-markup" {
|
||||
hasMarkup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
zr.Close()
|
||||
if !hasMarkup {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 将校验通过的临时文件移动到固定路径 ./data/backup.zip
|
||||
finalPath := filepath.Join(".", "data", "backup.zip")
|
||||
// 如存在旧文件,先删除
|
||||
_ = os.Remove(finalPath)
|
||||
if err := os.Rename(tempFilePath, finalPath); err != nil {
|
||||
// fallback:拷贝
|
||||
in, err2 := os.Open(tempFilePath)
|
||||
if err2 != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
out, err2 := os.Create(finalPath)
|
||||
if err2 != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
|
||||
return
|
||||
}
|
||||
if _, err2 = io.Copy(out, in); err2 != nil {
|
||||
out.Close()
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
|
||||
// 返回:已保存备份,重启后将自动恢复
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "success",
|
||||
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
|
||||
"path": "./data/backup.zip",
|
||||
})
|
||||
|
||||
go func() {
|
||||
log.Println("Backup uploaded, restarting service in 2 seconds to apply on startup...")
|
||||
time.Sleep(2 * time.Second)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
func RegisterClient(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
AutoDiscoveryKey, err := config.GetAs[string](config.AutoDiscoveryKeyKey, "")
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get AutoDiscovery Key: "+err.Error())
|
||||
return
|
||||
}
|
||||
if AutoDiscoveryKey == "" ||
|
||||
len(AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+AutoDiscoveryKey != auth {
|
||||
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
name = utils.GenerateRandomString(8)
|
||||
}
|
||||
name = "Auto-" + name
|
||||
uuid, token, err := clients.CreateClientWithName(name)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to create client: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetPingTasks(c *gin.Context) {
|
||||
uuid, _ := c.Get("client_uuid")
|
||||
clientUUID, _ := uuid.(string)
|
||||
if clientUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
|
||||
return
|
||||
}
|
||||
|
||||
result := tasks.GetPingTasksByClient(clientUUID)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func UploadPingResult(c *gin.Context) {
|
||||
uuid, _ := c.Get("client_uuid")
|
||||
clientUUID, _ := uuid.(string)
|
||||
if clientUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TaskID uint `json:"task_id" binding:"required"`
|
||||
Value int `json:"value"`
|
||||
PingType string `json:"ping_type"`
|
||||
FinishedAt time.Time `json:"finished_at" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
record := models.PingRecord{
|
||||
Client: clientUUID,
|
||||
TaskId: req.TaskID,
|
||||
Value: req.Value,
|
||||
Time: models.FromTime(req.FinishedAt),
|
||||
}
|
||||
if err := tasks.SavePingRecord(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save ping result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
+175
-25
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
@@ -15,9 +16,86 @@ import (
|
||||
"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/notifier"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// 如果超过这个时间没有收到任何消息,则认为连接已死
|
||||
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
|
||||
readWait = 11 * time.Second
|
||||
)
|
||||
|
||||
// postPresenceEntry 保存单个客户端的 POST 上报会话状态
|
||||
type postPresenceEntry struct {
|
||||
connID int64
|
||||
timer *time.Timer
|
||||
generation uint64 // 每次 Reset 递增,用于回调中判断是否为过期的旧回调
|
||||
}
|
||||
|
||||
var (
|
||||
postPresenceMu sync.Mutex
|
||||
postPresenceStates = make(map[string]*postPresenceEntry)
|
||||
)
|
||||
|
||||
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
|
||||
// 每次 POST 刷新 TTL 定时器;定时器到期后触发离线通知。
|
||||
func refreshPostPresence(uuid string) {
|
||||
postPresenceMu.Lock()
|
||||
defer postPresenceMu.Unlock()
|
||||
|
||||
if entry, exists := postPresenceStates[uuid]; exists {
|
||||
// 已在线:递增 generation 使可能正在执行的旧回调失效
|
||||
entry.generation++
|
||||
entry.timer.Stop()
|
||||
// 重新创建 AfterFunc 以在闭包中捕获新的 generation
|
||||
gen := entry.generation
|
||||
entry.timer = time.AfterFunc(readWait, func() {
|
||||
postPresenceExpired(uuid, entry.connID, gen)
|
||||
})
|
||||
ws.KeepAlivePresence(uuid, entry.connID, readWait)
|
||||
return
|
||||
}
|
||||
|
||||
// 新 POST 会话:生成 connID,标记在线,启动超时定时器
|
||||
connID := time.Now().UnixNano()
|
||||
ws.KeepAlivePresence(uuid, connID, readWait)
|
||||
go notifier.OnlineNotification(uuid, connID)
|
||||
|
||||
defaultGeneration := uint64(0)
|
||||
|
||||
entry := &postPresenceEntry{
|
||||
connID: connID,
|
||||
generation: defaultGeneration,
|
||||
}
|
||||
|
||||
entry.timer = time.AfterFunc(readWait, func() {
|
||||
postPresenceExpired(uuid, connID, defaultGeneration)
|
||||
})
|
||||
|
||||
postPresenceStates[uuid] = entry
|
||||
}
|
||||
|
||||
// postPresenceExpired 是定时器到期的回调。
|
||||
// 只有当 connID 和 generation 都与当前 entry 匹配时才执行离线清理,
|
||||
// 避免 timer.Reset 竞态导致过期回调错误地清除仍活跃的会话。
|
||||
func postPresenceExpired(uuid string, connID int64, gen uint64) {
|
||||
postPresenceMu.Lock()
|
||||
e, ok := postPresenceStates[uuid]
|
||||
if !ok || e.connID != connID || e.generation != gen {
|
||||
postPresenceMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(postPresenceStates, uuid)
|
||||
postPresenceMu.Unlock()
|
||||
|
||||
ws.SetPresence(uuid, connID, false)
|
||||
notifier.OfflineNotification(uuid, connID)
|
||||
}
|
||||
|
||||
func UploadReport(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
@@ -40,14 +118,31 @@ func UploadReport(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(report.UUID, report)
|
||||
|
||||
// 优先使用 body 中的 UUID,若为空则从中间件注入的上下文中获取
|
||||
uuid := report.UUID
|
||||
if uuid == "" {
|
||||
if v, ok := c.Get("client_uuid"); ok {
|
||||
uuid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
|
||||
return
|
||||
}
|
||||
report.UUID = uuid
|
||||
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
|
||||
return
|
||||
}
|
||||
// Update report with method and token
|
||||
|
||||
ws.LatestReport[report.UUID] = &report
|
||||
ws.SetLatestReport(uuid, &report)
|
||||
|
||||
// POST 上报后标记节点在线,超时未收到新 POST 则触发离线
|
||||
refreshPostPresence(uuid)
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
@@ -61,15 +156,16 @@ func WebSocketReport(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
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"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
|
||||
return
|
||||
}
|
||||
conn := ws.NewSafeConn(unsafeConn)
|
||||
defer conn.Close()
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
@@ -104,46 +200,100 @@ func WebSocketReport(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许一个客户端的连接
|
||||
if _, exists := ws.ConnectedClients[uuid]; exists {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
|
||||
// 接受新连接,并处理旧连接
|
||||
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
|
||||
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
|
||||
|
||||
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
|
||||
go oldConn.Close()
|
||||
}
|
||||
ws.SetConnectedClients(uuid, conn)
|
||||
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
|
||||
go notifier.OnlineNotification(uuid, conn.ID)
|
||||
defer func() {
|
||||
ws.DeleteClientConditionally(uuid, conn)
|
||||
notifier.OfflineNotification(uuid, conn.ID)
|
||||
}()
|
||||
|
||||
// 首先处理第一次ws conn收到的消息
|
||||
processMessage(conn, message, uuid)
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(readWait))
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("Client %s connection error: %v", uuid, err)
|
||||
}
|
||||
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
|
||||
}
|
||||
processMessage(conn, message, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// 将消息处理逻辑提取到一个函数中,方便复用
|
||||
func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
ws.ConnectedClients[uuid] = conn
|
||||
defer func() {
|
||||
delete(ws.ConnectedClients, uuid)
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
break
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
return
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
ws.LatestReport[uuid] = &report
|
||||
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"})
|
||||
return
|
||||
}
|
||||
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 {
|
||||
if api.Records[uuid] == nil {
|
||||
api.Records[uuid] = []common.Report{}
|
||||
reports, _ := api.Records.Get(uuid)
|
||||
if reports == nil {
|
||||
reports = []common.Report{}
|
||||
}
|
||||
if report.CPU.Usage < 0.01 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
api.Records[uuid] = append(api.Records[uuid], report)
|
||||
reports = append(reports.([]common.Report), report)
|
||||
api.Records.Set(uuid, reports, cache.DefaultExpiration)
|
||||
|
||||
return nil
|
||||
}
|
||||
+3
-2
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,7 @@ func TaskResult(c *gin.Context) {
|
||||
}
|
||||
var req struct {
|
||||
TaskId string `json:"task_id" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
Result string `json:"result"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
FinishedAt time.Time `json:"finished_at" binding:"required"`
|
||||
}
|
||||
@@ -26,7 +27,7 @@ func TaskResult(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,13 +3,25 @@ package client
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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 getClientIPType(ip net.IP) int {
|
||||
// 0:ipv4 1:ipv6 -1:错误的输入
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func UploadBasicInfo(c *gin.Context) {
|
||||
var cbi = map[string]interface{}{}
|
||||
if err := c.ShouldBindJSON(&cbi); err != nil {
|
||||
@@ -25,19 +37,41 @@ func UploadBasicInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
// 如果没有传入 IP 信息,尝试从请求中获取
|
||||
if (func() bool {
|
||||
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
|
||||
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})() {
|
||||
ipStr := c.ClientIP()
|
||||
ip := net.ParseIP(ipStr)
|
||||
ipType := getClientIPType(ip)
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
switch ipType {
|
||||
case 0:
|
||||
cbi["ipv4"] = ipStr
|
||||
case 1:
|
||||
cbi["ipv6"] = ipStr
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package jsonRpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
|
||||
// group: 调用者的权限分组 (guest/client/admin)
|
||||
// method: RPC 方法名 (如 "common:getNodes")
|
||||
// params: 方法参数
|
||||
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
|
||||
// 检查私有站点
|
||||
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
|
||||
|
||||
if PrivateSite && group == "guest" {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
|
||||
}
|
||||
|
||||
// 解析方法命名空间
|
||||
fc := strings.Split(method, ":")
|
||||
namespace := "common"
|
||||
if len(fc) >= 2 {
|
||||
namespace = fc[0]
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
allowed := false
|
||||
switch namespace {
|
||||
case "guest", "", "rpc", "common":
|
||||
allowed = true
|
||||
case "client":
|
||||
if group == "client" || group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
case "admin":
|
||||
if group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
|
||||
}
|
||||
|
||||
// 构建 context meta
|
||||
meta := &rpc.ContextMeta{Permission: group}
|
||||
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
|
||||
}
|
||||
|
||||
// Json Rpc2 over websocket, /api/rpc2
|
||||
func OnRpcRequest(c *gin.Context) {
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey)
|
||||
|
||||
// GET -> WebSocket
|
||||
if c.Request.Method == http.MethodGet {
|
||||
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
})
|
||||
conn := ws.NewSafeConn(_conn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
defer conn.Close()
|
||||
for {
|
||||
var req rpc.JsonRpcRequest
|
||||
err := conn.ReadJSON(&req)
|
||||
if err != nil {
|
||||
// JSON 解析
|
||||
var se *json.SyntaxError
|
||||
var ute *json.UnmarshalTypeError
|
||||
if errors.As(err, &se) || errors.As(err, &ute) {
|
||||
conn.WriteJSON(rpc.ErrorResponse(nil, rpc.InvalidRequest, "bad request: "+err.Error(), nil))
|
||||
continue
|
||||
}
|
||||
// 其它视为连接/IO 错误
|
||||
break
|
||||
}
|
||||
if jerr := req.Validate(); jerr != nil {
|
||||
conn.WriteJSON(jerr.ResponseWithID(req.ID))
|
||||
continue
|
||||
}
|
||||
dispatchByPermissionWithMeta(conn, permissionGroup, meta, &req)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 否则按 HTTP POST JSON-RPC 处理 (支持单个或批量)
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, rpc.ErrorResponse(nil, rpc.ParseError, "read body error", err.Error()))
|
||||
return
|
||||
}
|
||||
requests, jerr := rpc.ParseRequests(body)
|
||||
if jerr != nil {
|
||||
c.JSON(http.StatusBadRequest, jerr.Response())
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
// 批量
|
||||
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
|
||||
for _, rreq := range requests {
|
||||
// 权限与命名空间
|
||||
fc := strings.Split(rreq.Method, ":")
|
||||
if len(fc) == 1 {
|
||||
fc[0] = "common"
|
||||
}
|
||||
allowed := false
|
||||
switch fc[0] {
|
||||
case "guest", "", "rpc", "common":
|
||||
allowed = true
|
||||
case "client":
|
||||
if permissionGroup == "client" || permissionGroup == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
case "admin":
|
||||
if permissionGroup == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
default:
|
||||
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
|
||||
continue
|
||||
}
|
||||
if !allowed {
|
||||
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
|
||||
continue
|
||||
}
|
||||
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
|
||||
}
|
||||
// 单个请求直接对象,批量请求数组 (符合 JSON-RPC 2.0)
|
||||
if len(responses) == 1 {
|
||||
c.JSON(http.StatusOK, responses[0])
|
||||
} else {
|
||||
c.JSON(http.StatusOK, responses)
|
||||
}
|
||||
}
|
||||
|
||||
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
|
||||
func detectPermissionGroup(c *gin.Context) string {
|
||||
permissionGroup := "guest"
|
||||
token := c.Query("Authorization")
|
||||
if _, err := clients.GetClientUUIDByToken(token); err == nil {
|
||||
permissionGroup = "client"
|
||||
}
|
||||
if session_token, _ := c.Cookie("session_token"); session_token != "" {
|
||||
if _, err := accounts.GetUserBySession(session_token); err == nil {
|
||||
permissionGroup = "admin"
|
||||
}
|
||||
}
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if len(apiKey) <= len("Bearer ") {
|
||||
return permissionGroup
|
||||
}
|
||||
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
|
||||
permissionGroup = "admin"
|
||||
}
|
||||
return permissionGroup
|
||||
}
|
||||
|
||||
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
|
||||
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
|
||||
meta := &rpc.ContextMeta{Permission: permissionGroup}
|
||||
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
|
||||
token := c.Query("Authorization")
|
||||
if token == "" {
|
||||
// 兼容 header Bearer <token>
|
||||
hAuth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(hAuth, "Bearer ") {
|
||||
token = strings.TrimPrefix(hAuth, "Bearer ")
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
|
||||
meta.ClientToken = token
|
||||
meta.ClientUUID = uuid
|
||||
}
|
||||
}
|
||||
// 提取用户 (session cookie)
|
||||
if session_token, _ := c.Cookie("session_token"); session_token != "" {
|
||||
if user, err := accounts.GetUserBySession(session_token); err == nil {
|
||||
meta.User = &user
|
||||
meta.UserUUID = user.UUID
|
||||
}
|
||||
}
|
||||
meta.RemoteIP = c.ClientIP()
|
||||
meta.UserAgent = c.GetHeader("User-Agent")
|
||||
return meta
|
||||
}
|
||||
|
||||
// dispatchByPermissionWithMeta 与原函数类似,但会携带 meta 上下文给 handler
|
||||
func dispatchByPermissionWithMeta(conn *ws.SafeConn, permissionGroup string, meta *rpc.ContextMeta, req *rpc.JsonRpcRequest) {
|
||||
fc := strings.Split(req.Method, ":")
|
||||
if len(fc) == 1 {
|
||||
fc[0] = "common"
|
||||
}
|
||||
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
|
||||
switch fc[0] {
|
||||
case "guest", "", "rpc", "common":
|
||||
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
|
||||
case "client":
|
||||
if permissionGroup == "client" || permissionGroup == "admin" {
|
||||
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
|
||||
} else {
|
||||
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
|
||||
}
|
||||
case "admin":
|
||||
if permissionGroup == "admin" {
|
||||
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
|
||||
} else {
|
||||
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
|
||||
}
|
||||
default:
|
||||
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
|
||||
}
|
||||
}
|
||||
|
||||
// registry holds method handlers keyed by "namespace:MethodName"
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = make(map[string]reflect.Value)
|
||||
)
|
||||
|
||||
// Register 将以分组common注册
|
||||
func Register(name string, cb rpc.Handler) error {
|
||||
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
|
||||
Name: name,
|
||||
Summary: "This method does not provide a summary",
|
||||
Description: "This method does not provide a description",
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterWithGroup 将回调按分组注册。
|
||||
// 传入函数: Register("ns", someFunc) -> 注册 ns:SomeFunc
|
||||
// group 为空时使用默认分组 "common"。
|
||||
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
|
||||
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
|
||||
}
|
||||
|
||||
// GetRegisteredKeys 返回已注册的方法 key 列表 (调试用)。
|
||||
func GetRegisteredKeys() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
keys := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package jsonRpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
// pingstats:<uuid>
|
||||
var pingStatsCache = cache.New(1*time.Minute, 2*time.Minute)
|
||||
|
||||
type pingStat struct {
|
||||
Name string `json:"name"`
|
||||
Latest int `json:"latest"`
|
||||
Avg int `json:"avg"`
|
||||
Tail float64 `json:"tail"` // (P99-P50)/P50
|
||||
Loss float64 `json:"loss"` // 丢包率 %
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// getPingStatsForNode 计算并缓存节点最近 1 小时 ping 统计
|
||||
func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pingStat {
|
||||
if uuid == "" {
|
||||
return map[string]pingStat{}
|
||||
}
|
||||
key := fmt.Sprintf("pingstats:%s", uuid)
|
||||
if v, ok := pingStatsCache.Get(key); ok {
|
||||
if m, ok2 := v.(map[string]pingStat); ok2 {
|
||||
return m
|
||||
}
|
||||
}
|
||||
// 筛选属于该节点的任务
|
||||
assigned := make([]models.PingTask, 0, 4)
|
||||
for _, t := range pingTasks {
|
||||
if t.AppliesToClient(uuid) {
|
||||
assigned = append(assigned, t)
|
||||
}
|
||||
}
|
||||
if len(assigned) == 0 {
|
||||
empty := map[string]pingStat{}
|
||||
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
|
||||
return empty
|
||||
}
|
||||
end := time.Now()
|
||||
start := end.Add(-1 * time.Hour)
|
||||
recs, err := tasks.GetPingRecords(uuid, -1, start, end)
|
||||
if err != nil || len(recs) == 0 {
|
||||
empty := map[string]pingStat{}
|
||||
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
|
||||
return empty
|
||||
}
|
||||
grouped := make(map[uint][]models.PingRecord)
|
||||
for _, r := range recs {
|
||||
for _, t := range assigned {
|
||||
if r.TaskId == t.Id {
|
||||
grouped[r.TaskId] = append(grouped[r.TaskId], r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make(map[string]pingStat, len(grouped))
|
||||
for _, t := range assigned {
|
||||
records := grouped[t.Id]
|
||||
if len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
latest := -1
|
||||
var latestTs time.Time
|
||||
values := make([]int, 0, len(records))
|
||||
sum := 0
|
||||
valid := 0
|
||||
total := 0
|
||||
lossCount := 0
|
||||
minLat := 0
|
||||
maxLat := 0
|
||||
for _, r := range records {
|
||||
total++
|
||||
if r.Value < 0 { // 丢包
|
||||
lossCount++
|
||||
continue
|
||||
}
|
||||
values = append(values, r.Value)
|
||||
sum += r.Value
|
||||
valid++
|
||||
if minLat == 0 || r.Value < minLat {
|
||||
minLat = r.Value
|
||||
}
|
||||
if r.Value > maxLat {
|
||||
maxLat = r.Value
|
||||
}
|
||||
ts := r.Time.ToTime()
|
||||
if latestTs.IsZero() || ts.After(latestTs) {
|
||||
latestTs = ts
|
||||
latest = r.Value
|
||||
}
|
||||
}
|
||||
avg := 0
|
||||
if valid > 0 {
|
||||
avg = sum / valid
|
||||
}
|
||||
p50, p99 := 0, 0
|
||||
if len(values) > 0 {
|
||||
sort.Ints(values)
|
||||
percentile := func(vals []int, pct float64) int {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
if pct <= 0 {
|
||||
return vals[0]
|
||||
}
|
||||
if pct >= 1 {
|
||||
return vals[len(vals)-1]
|
||||
}
|
||||
pos := (float64(len(vals) - 1)) * pct
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return vals[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
v := float64(vals[lo]) + (float64(vals[hi])-float64(vals[lo]))*frac
|
||||
return int(math.Round(v))
|
||||
}
|
||||
p50 = percentile(values, 0.50)
|
||||
p99 = percentile(values, 0.99)
|
||||
}
|
||||
tail := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
tail = float64(p99-p50) / float64(p50)
|
||||
}
|
||||
lossRate := 0.0
|
||||
if total > 0 {
|
||||
lossRate = float64(lossCount) / float64(total) * 100
|
||||
}
|
||||
result[fmt.Sprintf("%d", t.Id)] = pingStat{
|
||||
Name: t.Name,
|
||||
Latest: latest,
|
||||
Avg: avg,
|
||||
Tail: tail,
|
||||
Loss: lossRate,
|
||||
Min: minLat,
|
||||
Max: maxLat,
|
||||
}
|
||||
}
|
||||
pingStatsCache.Set(key, result, cache.DefaultExpiration)
|
||||
return result
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterWithGroupAndMeta("getNodes", "common",
|
||||
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
return getNodes(ctx, req)
|
||||
},
|
||||
&rpc.MethodMeta{
|
||||
Name: "getNodes",
|
||||
Summary: "Get all nodes",
|
||||
Params: []rpc.ParamMeta{
|
||||
{
|
||||
Name: "uuid",
|
||||
Description: "Specify the UUID of the node",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
Returns: "Client | { [uuid]: Client }",
|
||||
},
|
||||
)
|
||||
RegisterWithGroupAndMeta("getNodesLatestStatus", "common",
|
||||
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
return getNodesLatestStatus(ctx, req)
|
||||
},
|
||||
&rpc.MethodMeta{
|
||||
Name: "getNodesLatestStatus",
|
||||
Summary: "Get latest status reports (single node or map).",
|
||||
Params: []rpc.ParamMeta{
|
||||
{
|
||||
Name: "uuid",
|
||||
Description: "Specify the UUID of the node (optional)",
|
||||
Required: false,
|
||||
Type: "string",
|
||||
},
|
||||
{
|
||||
Name: "uuids",
|
||||
Description: "Specify multiple UUIDs (array) to get subset (ignored if uuid provided)",
|
||||
Required: false,
|
||||
Type: "string[]",
|
||||
},
|
||||
},
|
||||
Returns: "Record | { [uuid]: Record }",
|
||||
},
|
||||
)
|
||||
Register("getMe", func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
return getMe(ctx, req)
|
||||
})
|
||||
Register("getPublicInfo", getPublicInfo)
|
||||
Register("getVersion", getVersion)
|
||||
Register("getNodeRecentStatus", getNodeRecentStatus)
|
||||
}
|
||||
|
||||
func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
var params struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
|
||||
}
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
SendIpAddrToGuest, _ := config.GetAs[bool](config.SendIpAddrToGuestKey)
|
||||
if meta.Permission != "admin" {
|
||||
// 过滤 Hidden 节点并隐藏敏感字段
|
||||
filtered := make([]models.Client, 0, len(cinfo))
|
||||
for _, node := range cinfo {
|
||||
if node.Hidden { // 非 admin 不显示隐藏节点
|
||||
continue
|
||||
}
|
||||
if SendIpAddrToGuest {
|
||||
if node.IPv4 != "" {
|
||||
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
|
||||
}
|
||||
if node.IPv6 != "" {
|
||||
node.IPv6 = strings.Split(node.IPv6, ":")[0] + ":*:*:*:*:*:*:*"
|
||||
}
|
||||
} else {
|
||||
node.IPv4 = ""
|
||||
node.IPv6 = ""
|
||||
}
|
||||
|
||||
node.Remark = ""
|
||||
node.Version = ""
|
||||
node.Token = ""
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
cinfo = filtered
|
||||
}
|
||||
if params.UUID != "" {
|
||||
for _, node := range cinfo {
|
||||
if node.UUID == params.UUID {
|
||||
return node, nil
|
||||
}
|
||||
}
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
|
||||
}
|
||||
|
||||
nodesMap := make(map[string]models.Client, len(cinfo))
|
||||
for _, node := range cinfo {
|
||||
nodesMap[node.UUID] = node
|
||||
}
|
||||
return nodesMap, nil
|
||||
}
|
||||
|
||||
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
info, err := database.GetPublicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get public info", err.Error())
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
var params struct {
|
||||
UUID string `json:"uuid"`
|
||||
UUIDs []string `json:"uuids"`
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
|
||||
onlineUUIDs := ws.GetAllOnlineUUIDs()
|
||||
onlineSet := make(map[string]bool, len(onlineUUIDs))
|
||||
for _, uuid := range onlineUUIDs {
|
||||
onlineSet[uuid] = true
|
||||
}
|
||||
|
||||
// Hidden 过滤
|
||||
if meta.Permission != "admin" {
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
|
||||
}
|
||||
hidden := make(map[string]bool, len(cinfo))
|
||||
for _, c := range cinfo {
|
||||
if c.Hidden {
|
||||
hidden[c.UUID] = true
|
||||
}
|
||||
}
|
||||
for uuid := range latest {
|
||||
if hidden[uuid] {
|
||||
delete(latest, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果指定 uuid 但找不到,直接返回 not found
|
||||
if params.UUID != "" {
|
||||
if _, ok := latest[params.UUID]; !ok {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
type recordLike struct {
|
||||
Client string `json:"client"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Cpu float32 `json:"cpu"`
|
||||
Gpu float32 `json:"gpu"`
|
||||
Ram int64 `json:"ram"`
|
||||
RamTotal int64 `json:"ram_total"`
|
||||
Swap int64 `json:"swap"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
Load float32 `json:"load"`
|
||||
Load5 float32 `json:"load5"`
|
||||
Load15 float32 `json:"load15"`
|
||||
Temp float32 `json:"temp"`
|
||||
Disk int64 `json:"disk"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
NetIn int64 `json:"net_in"`
|
||||
NetOut int64 `json:"net_out"`
|
||||
NetTotalUp int64 `json:"net_total_up"`
|
||||
NetTotalDown int64 `json:"net_total_down"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
Online bool `json:"online"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Ping map[string]pingStat `json:"ping"`
|
||||
}
|
||||
|
||||
respMap := make(map[string]recordLike, len(latest))
|
||||
|
||||
// 预取所有 ping 任务
|
||||
pingTasks, _ := tasks.GetAllPingTasks()
|
||||
|
||||
appendOne := func(uuid string, rep *common.Report) {
|
||||
if rep == nil {
|
||||
return
|
||||
}
|
||||
stats := getPingStatsForNode(uuid, pingTasks)
|
||||
rl := recordLike{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(rep.UpdatedAt),
|
||||
Cpu: float32(rep.CPU.Usage),
|
||||
Gpu: 0,
|
||||
Ram: rep.Ram.Used,
|
||||
RamTotal: rep.Ram.Total,
|
||||
Swap: rep.Swap.Used,
|
||||
SwapTotal: rep.Swap.Total,
|
||||
Load: float32(rep.Load.Load1),
|
||||
Load5: float32(rep.Load.Load5),
|
||||
Load15: float32(rep.Load.Load15),
|
||||
Temp: 0,
|
||||
Disk: rep.Disk.Used,
|
||||
DiskTotal: rep.Disk.Total,
|
||||
NetIn: rep.Network.Down,
|
||||
NetOut: rep.Network.Up,
|
||||
NetTotalUp: rep.Network.TotalUp,
|
||||
NetTotalDown: rep.Network.TotalDown,
|
||||
Process: rep.Process,
|
||||
Connections: rep.Connections.TCP + rep.Connections.UDP,
|
||||
ConnectionsUdp: rep.Connections.UDP,
|
||||
Online: onlineSet[uuid],
|
||||
Uptime: rep.Uptime,
|
||||
Ping: stats,
|
||||
}
|
||||
respMap[uuid] = rl
|
||||
}
|
||||
|
||||
// 选择逻辑
|
||||
if params.UUID != "" { // 单个
|
||||
appendOne(params.UUID, latest[params.UUID])
|
||||
return respMap[params.UUID], nil
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
if len(params.UUIDs) > 0 {
|
||||
for _, id := range params.UUIDs {
|
||||
selected[id] = true
|
||||
}
|
||||
for uuid, rep := range latest {
|
||||
if selected[uuid] {
|
||||
appendOne(uuid, rep)
|
||||
}
|
||||
}
|
||||
return respMap, nil
|
||||
}
|
||||
for uuid, rep := range latest {
|
||||
appendOne(uuid, rep)
|
||||
}
|
||||
return respMap, nil
|
||||
}
|
||||
|
||||
func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
var resp struct {
|
||||
TwoFAEnabled bool `json:"2fa_enabled"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
SSOId string `json:"sso_id"`
|
||||
SSOType string `json:"sso_type"`
|
||||
Username string `json:"username"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
switch meta.Permission {
|
||||
case "admin":
|
||||
resp.TwoFAEnabled = meta.User.TwoFactor != ""
|
||||
resp.LoggedIn = true
|
||||
resp.SSOId = meta.User.SSOID
|
||||
resp.SSOType = meta.User.SSOType
|
||||
resp.Username = meta.User.Username
|
||||
resp.UUID = meta.User.UUID
|
||||
return resp, nil
|
||||
case "guest":
|
||||
resp.LoggedIn = false
|
||||
return resp, nil
|
||||
case "client":
|
||||
resp.LoggedIn = true
|
||||
resp.SSOId = "client"
|
||||
resp.SSOType = "client"
|
||||
resp.Username = "client"
|
||||
resp.UUID = meta.ClientToken
|
||||
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
|
||||
if err != nil {
|
||||
resp.UUID = client
|
||||
}
|
||||
return resp, nil
|
||||
default:
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid user role", meta.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
return struct {
|
||||
Version string `json:"version"`
|
||||
Hash string `json:"hash"`
|
||||
}{
|
||||
Version: utils.CurrentVersion,
|
||||
Hash: utils.VersionHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
var params struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
if params.UUID == "" {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params)
|
||||
}
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
if meta.Permission == "admin" {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[params.UUID] {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params) //防止未登录用户获取隐藏客户端数据
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := api.Records.Get(params.UUID)
|
||||
reports, _ := raw.([]common.Report)
|
||||
|
||||
// 扁平化为 { count, records: [] }
|
||||
type flatRecord struct {
|
||||
Client string `json:"client"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Cpu float32 `json:"cpu"`
|
||||
Gpu float32 `json:"gpu"`
|
||||
Ram int64 `json:"ram"`
|
||||
RamTotal int64 `json:"ram_total"`
|
||||
Swap int64 `json:"swap"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
Load float32 `json:"load"`
|
||||
Temp float32 `json:"temp"`
|
||||
Disk int64 `json:"disk"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
NetIn int64 `json:"net_in"`
|
||||
NetOut int64 `json:"net_out"`
|
||||
NetTotalUp int64 `json:"net_total_up"`
|
||||
NetTotalDown int64 `json:"net_total_down"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Count int `json:"count"`
|
||||
Records []flatRecord `json:"records"`
|
||||
}{
|
||||
Count: 0,
|
||||
Records: []flatRecord{},
|
||||
}
|
||||
|
||||
if len(reports) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Records = make([]flatRecord, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
fr := flatRecord{
|
||||
Client: params.UUID,
|
||||
Time: models.FromTime(r.UpdatedAt),
|
||||
Cpu: float32(r.CPU.Usage),
|
||||
Gpu: 0,
|
||||
Ram: r.Ram.Used,
|
||||
RamTotal: r.Ram.Total,
|
||||
Swap: r.Swap.Used,
|
||||
SwapTotal: r.Swap.Total,
|
||||
Load: float32(r.Load.Load1),
|
||||
Temp: 0,
|
||||
Disk: r.Disk.Used,
|
||||
DiskTotal: r.Disk.Total,
|
||||
NetIn: r.Network.Down,
|
||||
NetOut: r.Network.Up,
|
||||
NetTotalUp: r.Network.TotalUp,
|
||||
NetTotalDown: r.Network.TotalDown,
|
||||
Process: r.Process,
|
||||
Connections: r.Connections.TCP + r.Connections.UDP,
|
||||
ConnectionsUdp: r.Connections.UDP,
|
||||
}
|
||||
resp.Records = append(resp.Records, fr)
|
||||
}
|
||||
resp.Count = len(resp.Records)
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
package jsonRpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
recordsdb "github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("getRecords", getRecords)
|
||||
}
|
||||
|
||||
func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
var params struct {
|
||||
Type string `json:"type"` // "load" | "ping"; default "load"
|
||||
UUID string `json:"uuid"` // client uuid; empty = all clients
|
||||
Hours int `json:"hours"` // time window in hours; default 1 if start/end not provided
|
||||
Start string `json:"start"` // RFC3339 start time (optional)
|
||||
End string `json:"end"` // RFC3339 end time (optional)
|
||||
LoadType string `json:"load_type"` // for type=load: cpu|gpu|ram|swap|load|temp|disk|network|process|connections|all
|
||||
TaskID int `json:"task_id"` // for type=ping: optional task id; -1 or omitted means all
|
||||
MaxCount int `json:"maxCount"` // max number of points; -1 unlimited; default 4000
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
|
||||
// defaults
|
||||
if params.Type == "" {
|
||||
params.Type = "load"
|
||||
}
|
||||
// parse time window
|
||||
var startTime, endTime time.Time
|
||||
if params.Start != "" || params.End != "" {
|
||||
// allow partial: missing end means now
|
||||
var err error
|
||||
if params.End == "" {
|
||||
endTime = time.Now()
|
||||
} else {
|
||||
endTime, err = time.Parse(time.RFC3339, params.End)
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid end time", params.End)
|
||||
}
|
||||
}
|
||||
if params.Start == "" {
|
||||
// default to 1 hour before end
|
||||
startTime = endTime.Add(-1 * time.Hour)
|
||||
} else {
|
||||
start, err := time.Parse(time.RFC3339, params.Start)
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid start time", params.Start)
|
||||
}
|
||||
startTime = start
|
||||
}
|
||||
} else {
|
||||
hours := params.Hours
|
||||
if hours <= 0 {
|
||||
hours = 1 // default 1 hour
|
||||
}
|
||||
endTime = time.Now()
|
||||
startTime = endTime.Add(-time.Duration(hours) * time.Hour)
|
||||
}
|
||||
|
||||
// Hidden filtering for non-admin
|
||||
isAdmin := meta.Permission == "admin"
|
||||
hidden := map[string]bool{}
|
||||
if !isAdmin {
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
|
||||
}
|
||||
for _, c := range cinfo {
|
||||
if c.Hidden {
|
||||
hidden[c.UUID] = true
|
||||
}
|
||||
}
|
||||
if params.UUID != "" && hidden[params.UUID] {
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "UUID not found", params.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
switch params.Type {
|
||||
case "load":
|
||||
// fetch load records
|
||||
recs, err := getLoadRecordsCombined(params.UUID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch records", err.Error())
|
||||
}
|
||||
// hidden filter on non-admin
|
||||
if !isAdmin {
|
||||
filtered := recs[:0]
|
||||
for _, r := range recs {
|
||||
if hidden[r.Client] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
recs = filtered
|
||||
}
|
||||
|
||||
// resolve maxCount default for load
|
||||
maxCount := params.MaxCount
|
||||
if maxCount == 0 {
|
||||
maxCount = 4000
|
||||
}
|
||||
|
||||
// optional load_type filtering -> group by client
|
||||
if params.LoadType != "" && params.LoadType != "all" {
|
||||
items := filterRecordsByLoadType(recs, params.LoadType)
|
||||
grouped := make(map[string][]flatRecord)
|
||||
for _, it := range items {
|
||||
grouped[it.Client] = append(grouped[it.Client], it)
|
||||
}
|
||||
// sort and count
|
||||
total := 0
|
||||
groupsMeta := make([]struct {
|
||||
name string
|
||||
length int
|
||||
}, 0, len(grouped))
|
||||
for name := range grouped {
|
||||
arr := grouped[name]
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
|
||||
grouped[name] = arr
|
||||
l := len(arr)
|
||||
total += l
|
||||
groupsMeta = append(groupsMeta, struct {
|
||||
name string
|
||||
length int
|
||||
}{name: name, length: l})
|
||||
}
|
||||
// downsample across all clients proportionally
|
||||
if maxCount != -1 && total > maxCount {
|
||||
targets := allocateTargets(groupsMeta, maxCount)
|
||||
total = 0
|
||||
for name, k := range targets {
|
||||
grouped[name] = downsampleFlatRecords(grouped[name], k)
|
||||
total += len(grouped[name])
|
||||
}
|
||||
}
|
||||
return struct {
|
||||
Count int `json:"count"`
|
||||
Records map[string][]flatRecord `json:"records"`
|
||||
LoadType string `json:"load_type"`
|
||||
From models.LocalTime `json:"from"`
|
||||
To models.LocalTime `json:"to"`
|
||||
}{Count: total, Records: grouped, LoadType: params.LoadType, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
|
||||
}
|
||||
// default: return full records, grouped by client
|
||||
grouped := make(map[string][]models.Record)
|
||||
for _, r := range recs {
|
||||
grouped[r.Client] = append(grouped[r.Client], r)
|
||||
}
|
||||
total := 0
|
||||
groupsMeta := make([]struct {
|
||||
name string
|
||||
length int
|
||||
}, 0, len(grouped))
|
||||
for name := range grouped {
|
||||
arr := grouped[name]
|
||||
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
|
||||
grouped[name] = arr
|
||||
l := len(arr)
|
||||
total += l
|
||||
groupsMeta = append(groupsMeta, struct {
|
||||
name string
|
||||
length int
|
||||
}{name: name, length: l})
|
||||
}
|
||||
if maxCount != -1 && total > maxCount {
|
||||
targets := allocateTargets(groupsMeta, maxCount)
|
||||
total = 0
|
||||
for name, k := range targets {
|
||||
grouped[name] = downsampleModelRecords(grouped[name], k)
|
||||
total += len(grouped[name])
|
||||
}
|
||||
}
|
||||
return struct {
|
||||
Count int `json:"count"`
|
||||
Records map[string][]models.Record `json:"records"`
|
||||
From models.LocalTime `json:"from"`
|
||||
To models.LocalTime `json:"to"`
|
||||
}{Count: total, Records: grouped, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
|
||||
|
||||
case "ping":
|
||||
taskId := params.TaskID
|
||||
if taskId == 0 {
|
||||
taskId = -1
|
||||
}
|
||||
recs, err := tasks.GetPingRecords(params.UUID, taskId, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping records", err.Error())
|
||||
}
|
||||
// hidden filter
|
||||
if !isAdmin {
|
||||
filtered := recs[:0]
|
||||
for _, r := range recs {
|
||||
if r.Client != "" && hidden[r.Client] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
recs = filtered
|
||||
}
|
||||
|
||||
type RecordsResp struct {
|
||||
TaskId uint `json:"task_id,omitempty"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Value int `json:"value"`
|
||||
Client string `json:"client,omitempty"`
|
||||
}
|
||||
type ClientBasicInfo struct {
|
||||
Client string `json:"client"`
|
||||
Loss float64 `json:"loss"`
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
type Resp struct {
|
||||
Count int `json:"count"`
|
||||
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
|
||||
Records []RecordsResp `json:"records"`
|
||||
Tasks []map[string]any `json:"tasks"`
|
||||
From models.LocalTime `json:"from"`
|
||||
To models.LocalTime `json:"to"`
|
||||
}
|
||||
|
||||
response := &Resp{Count: 0, Records: []RecordsResp{}, From: models.FromTime(startTime), To: models.FromTime(endTime)}
|
||||
|
||||
// stats per client
|
||||
clientStats := make(map[string]struct {
|
||||
total int
|
||||
loss int
|
||||
min int
|
||||
max int
|
||||
})
|
||||
|
||||
for _, r := range recs {
|
||||
rr := RecordsResp{
|
||||
TaskId: r.TaskId,
|
||||
Time: r.Time,
|
||||
Value: r.Value,
|
||||
Client: r.Client,
|
||||
}
|
||||
st := clientStats[r.Client]
|
||||
st.total++
|
||||
if r.Value < 0 {
|
||||
st.loss++
|
||||
} else {
|
||||
if st.min == 0 || r.Value < st.min {
|
||||
st.min = r.Value
|
||||
}
|
||||
if r.Value > st.max {
|
||||
st.max = r.Value
|
||||
}
|
||||
}
|
||||
clientStats[r.Client] = st
|
||||
response.Records = append(response.Records, rr)
|
||||
}
|
||||
|
||||
if len(clientStats) > 0 {
|
||||
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
|
||||
for client, st := range clientStats {
|
||||
if client != "" && !isAdmin && hidden[client] {
|
||||
continue
|
||||
}
|
||||
loss := float64(0)
|
||||
if st.total > 0 {
|
||||
loss = float64(st.loss) / float64(st.total) * 100
|
||||
}
|
||||
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
|
||||
Client: client,
|
||||
Loss: loss,
|
||||
Min: st.min,
|
||||
Max: st.max,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tasks summary (always included for ping type; do not expose target field)
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
|
||||
}
|
||||
toList := make([]map[string]any, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
if taskId != -1 && t.Id != uint(taskId) {
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
|
||||
if !t.AppliesToClient(params.UUID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
lossCount := 0
|
||||
minLat := 0
|
||||
maxLat := 0
|
||||
sum := 0
|
||||
valid := 0
|
||||
latestVal := -1
|
||||
var latestTs time.Time
|
||||
// 收集该任务的所有有效(非丢包)延迟值以计算百分位
|
||||
latencies := make([]int, 0, 64)
|
||||
for _, r := range recs {
|
||||
if r.TaskId != t.Id {
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" && r.Client != params.UUID {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.Value < 0 { // 丢包
|
||||
lossCount++
|
||||
continue
|
||||
}
|
||||
valid++
|
||||
sum += r.Value
|
||||
latencies = append(latencies, r.Value)
|
||||
if minLat == 0 || r.Value < minLat {
|
||||
minLat = r.Value
|
||||
}
|
||||
if r.Value > maxLat {
|
||||
maxLat = r.Value
|
||||
}
|
||||
// track latest non-negative value
|
||||
ts := r.Time.ToTime()
|
||||
if latestTs.IsZero() || ts.After(latestTs) {
|
||||
latestTs = ts
|
||||
latestVal = r.Value
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 P50 / P99
|
||||
p50 := 0
|
||||
p99 := 0
|
||||
if len(latencies) > 0 {
|
||||
sort.Ints(latencies)
|
||||
getPercentileInt := func(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if percentile <= 0 {
|
||||
return values[0]
|
||||
}
|
||||
if percentile >= 1 {
|
||||
return values[len(values)-1]
|
||||
}
|
||||
pos := (float64(len(values) - 1)) * percentile
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return values[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
v := float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac
|
||||
return int(math.Round(v))
|
||||
}
|
||||
p50 = getPercentileInt(latencies, 0.50)
|
||||
p99 = getPercentileInt(latencies, 0.99)
|
||||
}
|
||||
ratio := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
jitterMs := float64(p99 - p50)
|
||||
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
|
||||
ratio = jitterMs / adjustedBase
|
||||
}
|
||||
lossRate := 0.0
|
||||
if total > 0 {
|
||||
lossRate = float64(lossCount) / float64(total) * 100
|
||||
}
|
||||
avg := 0
|
||||
if valid > 0 {
|
||||
avg = sum / valid
|
||||
}
|
||||
info := map[string]any{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"interval": t.Interval,
|
||||
"default_on": t.DefaultOn,
|
||||
"loss": lossRate,
|
||||
"min": minLat,
|
||||
"max": maxLat,
|
||||
"avg": avg,
|
||||
"latest": latestVal,
|
||||
"total": total,
|
||||
"p50": p50,
|
||||
"p99": p99,
|
||||
"p99_p50_ratio": ratio,
|
||||
}
|
||||
if params.UUID == "" && taskId != -1 { // retain existing behavior of exposing clients only when filtering by task
|
||||
info["clients"] = t.Clients
|
||||
}
|
||||
toList = append(toList, info)
|
||||
}
|
||||
response.Tasks = toList
|
||||
// apply maxCount for ping
|
||||
maxCount := params.MaxCount
|
||||
if maxCount == 0 {
|
||||
maxCount = 4000
|
||||
}
|
||||
if maxCount != -1 && len(response.Records) > maxCount {
|
||||
// group records by TaskId for proportional downsampling
|
||||
taskGroups := make(map[uint][]RecordsResp)
|
||||
for _, r := range response.Records {
|
||||
taskGroups[r.TaskId] = append(taskGroups[r.TaskId], r)
|
||||
}
|
||||
|
||||
// sort each group by time
|
||||
for taskId := range taskGroups {
|
||||
sort.Slice(taskGroups[taskId], func(i, j int) bool {
|
||||
return taskGroups[taskId][i].Time.ToTime().Before(taskGroups[taskId][j].Time.ToTime())
|
||||
})
|
||||
}
|
||||
|
||||
// calculate proportional allocation for each task
|
||||
type taskMeta struct {
|
||||
taskId uint
|
||||
length int
|
||||
}
|
||||
groupsMeta := make([]taskMeta, 0, len(taskGroups))
|
||||
for taskId, records := range taskGroups {
|
||||
groupsMeta = append(groupsMeta, taskMeta{
|
||||
taskId: taskId,
|
||||
length: len(records),
|
||||
})
|
||||
}
|
||||
|
||||
// use existing allocateTargets function (create compatible struct)
|
||||
strTargets := allocateTargets(
|
||||
func() []struct {
|
||||
name string
|
||||
length int
|
||||
} {
|
||||
result := make([]struct {
|
||||
name string
|
||||
length int
|
||||
}, len(groupsMeta))
|
||||
for i, meta := range groupsMeta {
|
||||
result[i] = struct {
|
||||
name string
|
||||
length int
|
||||
}{name: fmt.Sprintf("%d", meta.taskId), length: meta.length}
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
maxCount,
|
||||
)
|
||||
|
||||
// downsample each task group
|
||||
downsampledRecords := make([]RecordsResp, 0, maxCount)
|
||||
samplePingRecords := func(in []RecordsResp, k int) []RecordsResp {
|
||||
n := len(in)
|
||||
if k <= 0 || n == 0 {
|
||||
return []RecordsResp{}
|
||||
}
|
||||
if k >= n {
|
||||
return in
|
||||
}
|
||||
out := make([]RecordsResp, 0, k)
|
||||
if k == 1 {
|
||||
out = append(out, in[n-1])
|
||||
return out
|
||||
}
|
||||
for i := 0; i < k; i++ {
|
||||
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
} else if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
out = append(out, in[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
for taskId, records := range taskGroups {
|
||||
targetKey := fmt.Sprintf("%d", taskId)
|
||||
targetCount := strTargets[targetKey]
|
||||
sampled := samplePingRecords(records, targetCount)
|
||||
downsampledRecords = append(downsampledRecords, sampled...)
|
||||
}
|
||||
|
||||
response.Records = downsampledRecords
|
||||
}
|
||||
response.Count = len(response.Records)
|
||||
// sort by time asc
|
||||
sort.Slice(response.Records, func(i, j int) bool {
|
||||
return response.Records[i].Time.ToTime().Before(response.Records[j].Time.ToTime())
|
||||
})
|
||||
return response, nil
|
||||
default:
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid type, expected 'load' or 'ping'", params.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers for load records ----------
|
||||
|
||||
// getLoadRecordsCombined fetches records for a client or all clients within a time range,
|
||||
// combining recent short-term table and long-term table with 15-min grouping for recent part.
|
||||
func getLoadRecordsCombined(uuid string, start, end time.Time) ([]models.Record, error) {
|
||||
// prefer the existing function when uuid provided
|
||||
if uuid != "" {
|
||||
return recordsdb.GetRecordsByClientAndTime(uuid, start, end)
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recent []models.Record
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
_ = db.Table("records").Where("time >= ? AND time <= ?", recentStart, end).Order("time ASC").Find(&recent).Error
|
||||
}
|
||||
|
||||
var longTerm []models.Record
|
||||
_ = db.Table("records_long_term").Where("time >= ? AND time <= ?", start, end).Order("time ASC").Find(&longTerm).Error
|
||||
|
||||
// if no long term, return all recent
|
||||
if len(longTerm) == 0 {
|
||||
return recent, nil
|
||||
}
|
||||
|
||||
// group recent by client+15min, keep latest in bucket
|
||||
type key struct {
|
||||
c string
|
||||
slot string
|
||||
}
|
||||
grouped := make(map[key]models.Record)
|
||||
for _, rec := range recent {
|
||||
k := key{c: rec.Client, slot: rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)}
|
||||
if old, ok := grouped[k]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
|
||||
grouped[k] = rec
|
||||
}
|
||||
}
|
||||
flat := make([]models.Record, 0, len(grouped))
|
||||
for _, rec := range grouped {
|
||||
flat = append(flat, rec)
|
||||
}
|
||||
sort.Slice(flat, func(i, j int) bool { return flat[i].Time.ToTime().Before(flat[j].Time.ToTime()) })
|
||||
flat = append(flat, longTerm...)
|
||||
return flat, nil
|
||||
}
|
||||
|
||||
// ---------- downsampling helpers ----------
|
||||
|
||||
// allocateTargets splits maxTotal across groups proportionally to their lengths.
|
||||
func allocateTargets(groups []struct {
|
||||
name string
|
||||
length int
|
||||
}, maxTotal int) map[string]int {
|
||||
result := make(map[string]int, len(groups))
|
||||
if maxTotal < 0 {
|
||||
for _, g := range groups {
|
||||
result[g.name] = g.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
total := 0
|
||||
for _, g := range groups {
|
||||
total += g.length
|
||||
}
|
||||
if total <= maxTotal {
|
||||
for _, g := range groups {
|
||||
result[g.name] = g.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
// initial allocation based on proportion
|
||||
type rem struct {
|
||||
idx int
|
||||
frac float64
|
||||
}
|
||||
remainders := make([]rem, 0, len(groups))
|
||||
sumTargets := 0
|
||||
for i, g := range groups {
|
||||
if g.length <= 0 {
|
||||
result[g.name] = 0
|
||||
continue
|
||||
}
|
||||
raw := float64(g.length) * float64(maxTotal) / float64(total)
|
||||
t := int(math.Floor(raw))
|
||||
if t > g.length {
|
||||
t = g.length
|
||||
}
|
||||
result[groups[i].name] = t
|
||||
sumTargets += t
|
||||
remainders = append(remainders, rem{i, raw - float64(t)})
|
||||
}
|
||||
// distribute remaining by largest fractional parts
|
||||
if sumTargets < maxTotal {
|
||||
need := maxTotal - sumTargets
|
||||
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac > remainders[j].frac })
|
||||
for _, r := range remainders {
|
||||
if need == 0 {
|
||||
break
|
||||
}
|
||||
g := groups[r.idx]
|
||||
cur := result[g.name]
|
||||
if cur < g.length {
|
||||
result[g.name] = cur + 1
|
||||
need--
|
||||
}
|
||||
}
|
||||
// if still left, second pass round-robin
|
||||
if need > 0 {
|
||||
for need > 0 {
|
||||
for _, g := range groups {
|
||||
if need == 0 {
|
||||
break
|
||||
}
|
||||
if result[g.name] < g.length {
|
||||
result[g.name]++
|
||||
need--
|
||||
}
|
||||
}
|
||||
if need > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if sumTargets > maxTotal {
|
||||
over := sumTargets - maxTotal
|
||||
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac < remainders[j].frac })
|
||||
for _, r := range remainders {
|
||||
if over == 0 {
|
||||
break
|
||||
}
|
||||
g := groups[r.idx]
|
||||
if result[g.name] > 0 {
|
||||
result[g.name]--
|
||||
over--
|
||||
}
|
||||
}
|
||||
if over > 0 {
|
||||
for over > 0 {
|
||||
for _, g := range groups {
|
||||
if over == 0 {
|
||||
break
|
||||
}
|
||||
if result[g.name] > 0 {
|
||||
result[g.name]--
|
||||
over--
|
||||
}
|
||||
}
|
||||
if over > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func downsampleModelRecords(in []models.Record, k int) []models.Record {
|
||||
n := len(in)
|
||||
if k <= 0 || n == 0 {
|
||||
return []models.Record{}
|
||||
}
|
||||
if k >= n {
|
||||
return in
|
||||
}
|
||||
out := make([]models.Record, 0, k)
|
||||
if k == 1 {
|
||||
out = append(out, in[n-1])
|
||||
return out
|
||||
}
|
||||
for i := 0; i < k; i++ {
|
||||
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
} else if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
out = append(out, in[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func downsampleFlatRecords(in []flatRecord, k int) []flatRecord {
|
||||
n := len(in)
|
||||
if k <= 0 || n == 0 {
|
||||
return []flatRecord{}
|
||||
}
|
||||
if k >= n {
|
||||
return in
|
||||
}
|
||||
out := make([]flatRecord, 0, k)
|
||||
if k == 1 {
|
||||
out = append(out, in[n-1])
|
||||
return out
|
||||
}
|
||||
for i := 0; i < k; i++ {
|
||||
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
} else if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
out = append(out, in[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flatRecord is a projection used when load_type is specified.
|
||||
type flatRecord struct {
|
||||
Client string `json:"client"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Cpu *float32 `json:"cpu,omitempty"`
|
||||
Gpu *float32 `json:"gpu,omitempty"`
|
||||
Ram *int64 `json:"ram,omitempty"`
|
||||
RamTotal *int64 `json:"ram_total,omitempty"`
|
||||
Swap *int64 `json:"swap,omitempty"`
|
||||
SwapTotal *int64 `json:"swap_total,omitempty"`
|
||||
Load *float32 `json:"load,omitempty"`
|
||||
Temp *float32 `json:"temp,omitempty"`
|
||||
Disk *int64 `json:"disk,omitempty"`
|
||||
DiskTotal *int64 `json:"disk_total,omitempty"`
|
||||
NetIn *int64 `json:"net_in,omitempty"`
|
||||
NetOut *int64 `json:"net_out,omitempty"`
|
||||
NetTotalUp *int64 `json:"net_total_up,omitempty"`
|
||||
NetTotalDown *int64 `json:"net_total_down,omitempty"`
|
||||
Process *int `json:"process,omitempty"`
|
||||
Connections *int `json:"connections,omitempty"`
|
||||
ConnectionsUdp *int `json:"connections_udp,omitempty"`
|
||||
Uptime *int64 `json:"uptime,omitempty"`
|
||||
}
|
||||
|
||||
func filterRecordsByLoadType(recs []models.Record, loadType string) []flatRecord {
|
||||
out := make([]flatRecord, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
fr := flatRecord{Client: r.Client, Time: r.Time}
|
||||
// always include uptime when present
|
||||
//if r.Uptime != 0 {
|
||||
// v := r.Uptime
|
||||
// fr.Uptime = &v
|
||||
//}
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
v := r.Cpu
|
||||
fr.Cpu = &v
|
||||
case "gpu":
|
||||
v := r.Gpu
|
||||
fr.Gpu = &v
|
||||
case "ram":
|
||||
v := r.Ram
|
||||
fr.Ram = &v
|
||||
vt := r.RamTotal
|
||||
fr.RamTotal = &vt
|
||||
case "swap":
|
||||
v := r.Swap
|
||||
fr.Swap = &v
|
||||
vt := r.SwapTotal
|
||||
fr.SwapTotal = &vt
|
||||
case "load":
|
||||
v := r.Load
|
||||
fr.Load = &v
|
||||
case "temp":
|
||||
v := r.Temp
|
||||
fr.Temp = &v
|
||||
case "disk":
|
||||
v := r.Disk
|
||||
fr.Disk = &v
|
||||
vt := r.DiskTotal
|
||||
fr.DiskTotal = &vt
|
||||
case "network":
|
||||
vi := r.NetIn
|
||||
vo := r.NetOut
|
||||
vtu := r.NetTotalUp
|
||||
vtd := r.NetTotalDown
|
||||
fr.NetIn = &vi
|
||||
fr.NetOut = &vo
|
||||
fr.NetTotalUp = &vtu
|
||||
fr.NetTotalDown = &vtd
|
||||
case "process":
|
||||
v := r.Process
|
||||
fr.Process = &v
|
||||
case "connections":
|
||||
v := r.Connections
|
||||
fr.Connections = &v
|
||||
vu := r.ConnectionsUdp
|
||||
fr.ConnectionsUdp = &vu
|
||||
default:
|
||||
// unknown type: fallback to all fields as a full record would be returned elsewhere
|
||||
v := r.Cpu
|
||||
fr.Cpu = &v
|
||||
}
|
||||
out = append(out, fr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, Records[uuid])
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFa string `json:"2fa_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := config.Get()
|
||||
if conf.DisablePasswordLogin {
|
||||
RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
}
|
||||
func Logout(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
accounts.DeleteSession(session)
|
||||
c.SetCookie("session_token", "", -1, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,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,25 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
if err != nil {
|
||||
RespondError(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Return public settings including CORS
|
||||
RespondSuccess(c, gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
"description": cst.Description,
|
||||
"custom_head": cst.CustomHead,
|
||||
"custom_body": cst.CustomBody,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package public
|
||||
|
||||
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/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
if uuid == "" {
|
||||
api.RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[uuid] {
|
||||
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
records, _ := api.Records.Get(uuid)
|
||||
api.RespondSuccess(c, records)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
|
||||
"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) {
|
||||
DisablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
|
||||
if DisablePasswordLogin {
|
||||
api.RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
api.RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" { // 开启了2FA
|
||||
if data.TwoFa == "" {
|
||||
api.RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
|
||||
api.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 {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
|
||||
api.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)
|
||||
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package public
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package public
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
+1607
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
package public
|
||||
|
||||
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/clients"
|
||||
)
|
||||
|
||||
func GetNodesInformation(c *gin.Context) {
|
||||
clientList, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
|
||||
return
|
||||
}
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err = accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 过滤掉 Hidden 的客户端,并清理需要隐藏的字段
|
||||
j := 0
|
||||
for i := 0; i < len(clientList); i++ {
|
||||
if clientList[i].Hidden && !isLogin { // 不返回 Hidden 客户端
|
||||
continue
|
||||
}
|
||||
clientList[i].IPv4 = ""
|
||||
clientList[i].IPv6 = ""
|
||||
clientList[i].Remark = "" // 私有备注不展示
|
||||
clientList[i].Version = ""
|
||||
clientList[i].Token = ""
|
||||
clientList[j] = clientList[i]
|
||||
j++
|
||||
}
|
||||
clientList = clientList[:j]
|
||||
|
||||
api.RespondSuccess(c, clientList)
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
package api
|
||||
package public
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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/database/auditlog"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
if !cfg.OAuthEnabled {
|
||||
OAuthEnabled, _ := config.GetAs[bool](config.OAuthEnabledKey, false)
|
||||
if !OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state := oauth.GetOAuthUrl()
|
||||
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
|
||||
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
@@ -27,29 +29,44 @@ func OAuth(c *gin.Context) {
|
||||
|
||||
// /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
|
||||
|
||||
// 获取当前OAuth提供商名称
|
||||
providerName := oauth.CurrentProvider().GetName()
|
||||
|
||||
providersSkipStateCheck := []string{"qq"}
|
||||
if slices.Contains(providersSkipStateCheck, providerName) {
|
||||
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
|
||||
// 但我们仍然需要验证state的存在性(不能是空的)
|
||||
if state == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 对于其他提供商,严格验证state匹配
|
||||
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
|
||||
queries := make(map[string]string)
|
||||
for key, values := range c.Request.URL.Query() {
|
||||
if len(values) > 0 {
|
||||
queries[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
githubUser, err := oauth.OAuthCallback(code, state)
|
||||
oidcUser, err := oauth.CurrentProvider().OnCallback(c, state, queries, utils.GetCallbackURL(c))
|
||||
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)
|
||||
// ID作为SSO ID
|
||||
sso_id := fmt.Sprintf("%s_%s", oauth.CurrentProvider().GetName(), oidcUser.UserId)
|
||||
|
||||
// 如果cookie中有binding_external_account,说明是绑定外部账号
|
||||
// 否则是登录
|
||||
@@ -68,8 +85,8 @@ func OAuthCallback(c *gin.Context) {
|
||||
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")
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
|
||||
c.Redirect(302, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,7 +95,7 @@ func OAuthCallback(c *gin.Context) {
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "error",
|
||||
"message": "please log in and bind your GitHub account first.",
|
||||
"message": "please log in and bind your external account first.",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -92,6 +109,6 @@ func OAuthCallback(c *gin.Context) {
|
||||
|
||||
// 设置cookie并返回
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
c.Redirect(302, "/manage")
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
c.Redirect(302, "/admin")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
// PublicPingTask 是对外暴露的延迟监测任务信息。
|
||||
type PublicPingTask struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Clients []string `json:"clients"`
|
||||
DefaultOn bool `json:"default_on"`
|
||||
Type string `json:"type"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
func GetPublicPingTasks(c *gin.Context) {
|
||||
tasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
publicTasks := make([]PublicPingTask, len(tasks))
|
||||
for i, task := range tasks {
|
||||
publicTasks[i] = PublicPingTask{
|
||||
Id: task.Id,
|
||||
Name: task.Name,
|
||||
Clients: task.Clients,
|
||||
DefaultOn: task.DefaultOn,
|
||||
Type: task.Type,
|
||||
Interval: task.Interval,
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, publicTasks)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
p, e := database.GetPublicInfo()
|
||||
if e != nil {
|
||||
api.RespondError(c, 500, e.Error())
|
||||
return
|
||||
}
|
||||
// 临时访问许可
|
||||
if func() bool {
|
||||
tempKey, err := c.Cookie("temp_key")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if allowTempKey == "" || tempKey != allowTempKey {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if tempKeyExpireTime < now {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}() {
|
||||
p["private_site"] = false
|
||||
}
|
||||
api.RespondSuccess(c, p)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
records "github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetRecordsByUUID(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
loadType := c.Query("load_type")
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[uuid] {
|
||||
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 验证 load_type 参数
|
||||
validLoadTypes := map[string]bool{
|
||||
"cpu": true, "ram": true, "swap": true,
|
||||
"load": true, "temp": true, "disk": true, "network": true,
|
||||
"process": true, "connections": true, "all": true, "": true,
|
||||
}
|
||||
|
||||
if !validLoadTypes[loadType] {
|
||||
api.RespondError(c, 400, "Invalid load_type parameter")
|
||||
return
|
||||
}
|
||||
|
||||
clientRecords, 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
|
||||
}
|
||||
|
||||
// 准备基本响应
|
||||
response := gin.H{
|
||||
"records": clientRecords,
|
||||
"count": len(clientRecords),
|
||||
}
|
||||
|
||||
// 如果有load_type过滤,应用过滤
|
||||
if loadType != "" && loadType != "all" {
|
||||
filteredRecords := filterRecordsByLoadType(clientRecords, loadType)
|
||||
response = gin.H{
|
||||
"records": filteredRecords,
|
||||
"count": len(filteredRecords),
|
||||
"load_type": loadType,
|
||||
}
|
||||
}
|
||||
|
||||
// 自动检测是否有GPU数据并附加到响应中
|
||||
if loadType == "" || loadType == "all" || loadType == "gpu" {
|
||||
gpuRecords, err := records.GetGPURecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
if err == nil && len(gpuRecords) > 0 {
|
||||
// 按设备索引分组数据,构建简化的设备结构
|
||||
gpuDevices := make(map[string]interface{})
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
deviceKey := strconv.Itoa(record.DeviceIndex)
|
||||
|
||||
// 如果设备还没有初始化,创建设备信息
|
||||
if gpuDevices[deviceKey] == nil {
|
||||
gpuDevices[deviceKey] = gin.H{
|
||||
"device_index": record.DeviceIndex,
|
||||
"device_name": record.DeviceName,
|
||||
"records": []models.GPURecord{},
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录到设备
|
||||
device := gpuDevices[deviceKey].(gin.H)
|
||||
records := device["records"].([]models.GPURecord)
|
||||
device["records"] = append(records, record)
|
||||
gpuDevices[deviceKey] = device
|
||||
}
|
||||
|
||||
// 添加优化后的GPU数据结构到响应
|
||||
response["gpu_devices"] = gpuDevices
|
||||
response["has_gpu_data"] = true
|
||||
} else {
|
||||
response["has_gpu_data"] = false
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, response)
|
||||
}
|
||||
|
||||
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
|
||||
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
|
||||
filteredRecords := make([]gin.H, 0, len(records))
|
||||
|
||||
for _, r := range records {
|
||||
record := gin.H{
|
||||
"client": r.Client,
|
||||
"time": r.Time,
|
||||
}
|
||||
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
record["cpu"] = r.Cpu
|
||||
case "gpu":
|
||||
record["gpu"] = r.Gpu
|
||||
case "ram":
|
||||
record["ram"] = r.Ram
|
||||
record["ram_total"] = r.RamTotal
|
||||
if r.RamTotal > 0 {
|
||||
record["ram_percent"] = float32(r.Ram) / float32(r.RamTotal) * 100
|
||||
}
|
||||
case "swap":
|
||||
record["swap"] = r.Swap
|
||||
record["swap_total"] = r.SwapTotal
|
||||
if r.SwapTotal > 0 {
|
||||
record["swap_percent"] = float32(r.Swap) / float32(r.SwapTotal) * 100
|
||||
}
|
||||
case "load":
|
||||
record["load"] = r.Load
|
||||
case "temp":
|
||||
record["temp"] = r.Temp
|
||||
case "disk":
|
||||
record["disk"] = r.Disk
|
||||
record["disk_total"] = r.DiskTotal
|
||||
if r.DiskTotal > 0 {
|
||||
record["disk_percent"] = float32(r.Disk) / float32(r.DiskTotal) * 100
|
||||
}
|
||||
case "network":
|
||||
record["net_in"] = r.NetIn
|
||||
record["net_out"] = r.NetOut
|
||||
record["net_total_up"] = r.NetTotalUp
|
||||
record["net_total_down"] = r.NetTotalDown
|
||||
case "process":
|
||||
record["process"] = r.Process
|
||||
case "connections":
|
||||
record["connections"] = r.Connections
|
||||
record["connections_udp"] = r.ConnectionsUdp
|
||||
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
|
||||
}
|
||||
|
||||
filteredRecords = append(filteredRecords, record)
|
||||
}
|
||||
|
||||
return filteredRecords
|
||||
}
|
||||
|
||||
// GET query: uuid string, task_id int, hours int
|
||||
// 支持三种查询模式:
|
||||
// 1. 仅 uuid - 获取该客户端的所有 ping 任务记录
|
||||
// 2. 仅 task_id - 获取该任务的所有客户端记录
|
||||
// 3. uuid + task_id - 获取特定客户端在特定任务的记录
|
||||
func GetPingRecords(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
taskIdStr := c.Query("task_id")
|
||||
|
||||
// 必须提供 uuid 或 task_id 其中至少一个
|
||||
if uuid == "" && taskIdStr == "" {
|
||||
api.RespondError(c, 400, "UUID or task_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
type RecordsResp struct {
|
||||
TaskId uint `json:"task_id,omitempty"`
|
||||
Time string `json:"time"`
|
||||
Value int `json:"value"`
|
||||
Client string `json:"client,omitempty"`
|
||||
}
|
||||
type ClientBasicInfo struct {
|
||||
Client string `json:"client"`
|
||||
Loss float64 `json:"loss"`
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
type Resp struct {
|
||||
Count int `json:"count"`
|
||||
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
|
||||
Records []RecordsResp `json:"records"`
|
||||
Tasks []gin.H `json:"tasks,omitempty"`
|
||||
}
|
||||
var records []models.PingRecord
|
||||
hiddenMap := map[string]bool{}
|
||||
response := &Resp{
|
||||
Count: 0,
|
||||
Records: []RecordsResp{},
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
if uuid != "" {
|
||||
if hiddenMap[uuid] {
|
||||
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hours := c.Query("hours")
|
||||
|
||||
if hours == "" {
|
||||
hours = "4"
|
||||
}
|
||||
|
||||
hoursInt, err := strconv.Atoi(hours)
|
||||
if err != nil {
|
||||
hoursInt = 4
|
||||
}
|
||||
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-time.Duration(hoursInt) * time.Hour)
|
||||
|
||||
// 解析 task_id,支持同时传入 uuid 和 task_id
|
||||
taskId := -1
|
||||
if taskIdStr != "" {
|
||||
taskId, err = strconv.Atoi(taskIdStr)
|
||||
if err != nil {
|
||||
api.RespondError(c, 400, "Invalid task_id parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 查询记录,现在支持 uuid + task_id 组合查询
|
||||
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 用于统计每个客户端的信息(按 task_id 查询时使用)
|
||||
clientStats := make(map[string]struct {
|
||||
total int
|
||||
loss int
|
||||
min int
|
||||
max int
|
||||
})
|
||||
|
||||
for _, r := range records {
|
||||
if r.Client != "" && !isLogin {
|
||||
if hiddenMap[r.Client] {
|
||||
continue // 跳过隐藏的节点
|
||||
}
|
||||
}
|
||||
toTime := r.Time.ToTime()
|
||||
rec := RecordsResp{
|
||||
Time: toTime.Format(time.RFC3339),
|
||||
Value: r.Value,
|
||||
}
|
||||
rec.Client = r.Client
|
||||
stats := clientStats[r.Client]
|
||||
stats.total++
|
||||
|
||||
if r.Value < 0 {
|
||||
stats.loss++
|
||||
} else {
|
||||
if stats.min == 0 || r.Value < stats.min {
|
||||
stats.min = r.Value
|
||||
}
|
||||
if r.Value > stats.max {
|
||||
stats.max = r.Value
|
||||
}
|
||||
}
|
||||
clientStats[r.Client] = stats
|
||||
rec.TaskId = r.TaskId
|
||||
|
||||
response.Records = append(response.Records, rec)
|
||||
}
|
||||
|
||||
// 返回 BasicInfo - 按客户端分组的统计信息
|
||||
// 在以下情况下特别有用:
|
||||
// 1. 仅 task_id 查询 - 查看该任务下所有客户端的表现
|
||||
// 2. uuid + task_id 查询 - 查看特定客户端在特定任务的表现
|
||||
if len(clientStats) > 0 {
|
||||
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
|
||||
for client, stats := range clientStats {
|
||||
if client != "" && !isLogin {
|
||||
if hiddenMap[client] {
|
||||
continue // 跳过隐藏的节点
|
||||
}
|
||||
}
|
||||
loss := float64(0)
|
||||
if stats.total > 0 {
|
||||
loss = float64(stats.loss) / float64(stats.total) * 100
|
||||
}
|
||||
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
|
||||
Client: client,
|
||||
Loss: loss,
|
||||
Min: stats.min,
|
||||
Max: stats.max,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
|
||||
// 这种情况下可以在响应中标记查询模式
|
||||
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
|
||||
// 这是精确查询模式
|
||||
}
|
||||
}
|
||||
|
||||
// 优化后的任务信息返回逻辑
|
||||
// 1. uuid != "" - 返回该客户端参与的所有任务信息
|
||||
// 2. uuid != "" && taskId != -1 - 返回该客户端在指定任务的信息
|
||||
// 3. taskId != -1 && uuid == "" - 返回该任务的所有客户端统计(通过 BasicInfo)
|
||||
if uuid != "" || taskId != -1 {
|
||||
// 获取所有 pingTasks
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tasksList := make([]gin.H, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
// 如果指定了 taskId,只处理该任务
|
||||
if taskId != -1 {
|
||||
if t.Id != uint(taskId) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 如果指定了 uuid,检查任务是否分配给该客户端
|
||||
if uuid != "" {
|
||||
if !t.AppliesToClient(uuid) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 计算该任务的丢包率和延迟统计
|
||||
totalCount := 0
|
||||
lossCount := 0
|
||||
minLatency := 0
|
||||
maxLatency := 0
|
||||
avgLatency := 0
|
||||
sumLatency := 0
|
||||
validCount := 0
|
||||
|
||||
for _, r := range records {
|
||||
// 根据查询模式过滤记录
|
||||
if r.TaskId != t.Id {
|
||||
continue
|
||||
}
|
||||
// 如果同时指定了 uuid 和 task_id,只统计该客户端的记录
|
||||
if uuid != "" && r.Client != uuid {
|
||||
continue
|
||||
}
|
||||
|
||||
totalCount++
|
||||
if r.Value < 0 {
|
||||
lossCount++
|
||||
} else {
|
||||
validCount++
|
||||
sumLatency += r.Value
|
||||
if minLatency == 0 || r.Value < minLatency {
|
||||
minLatency = r.Value
|
||||
}
|
||||
if r.Value > maxLatency {
|
||||
maxLatency = r.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lossRate float64 = 0
|
||||
if totalCount > 0 {
|
||||
lossRate = float64(lossCount) / float64(totalCount) * 100
|
||||
}
|
||||
if validCount > 0 {
|
||||
avgLatency = sumLatency / validCount
|
||||
}
|
||||
|
||||
taskInfo := gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"interval": t.Interval,
|
||||
"default_on": t.DefaultOn,
|
||||
"loss": lossRate,
|
||||
"min": minLatency,
|
||||
"max": maxLatency,
|
||||
"avg": avgLatency,
|
||||
"total": totalCount,
|
||||
}
|
||||
|
||||
// 如果是仅 task_id 查询,添加客户端列表信息
|
||||
if uuid == "" && taskId != -1 {
|
||||
taskInfo["clients"] = t.Clients
|
||||
}
|
||||
|
||||
tasksList = append(tasksList, taskInfo)
|
||||
}
|
||||
response.Tasks = tasksList
|
||||
}
|
||||
|
||||
response.Count = len(response.Records) // 计算最后结果保持计数一致
|
||||
api.RespondSuccess(c, response)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
func GetVersion(c *gin.Context) {
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"version": utils.CurrentVersion,
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func RequestTerminal(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
_, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 建立ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 新建一个终端连接
|
||||
id := utils.GenerateRandomString(32)
|
||||
session := &TerminalSession{
|
||||
UserUUID: user_uuid.(string),
|
||||
UUID: uuid,
|
||||
Browser: conn,
|
||||
Agent: nil,
|
||||
}
|
||||
|
||||
TerminalSessionsMutex.Lock()
|
||||
TerminalSessions[id] = session
|
||||
TerminalSessionsMutex.Unlock()
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
log.Println("Terminal connection closed:", code, text)
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
// 通知 Agent 关闭终端连接
|
||||
if session.Agent != nil {
|
||||
session.Agent.Close()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ws.ConnectedClients[uuid] == nil {
|
||||
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
err = ws.ConnectedClients[uuid].WriteJSON(gin.H{
|
||||
"message": "terminal",
|
||||
"request_id": id,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
|
||||
// 如果没有连接上,则关闭连接
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Agent == nil {
|
||||
if session.Browser != nil {
|
||||
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
|
||||
session.Browser.Close()
|
||||
}
|
||||
conn.Close()
|
||||
delete(TerminalSessions, id)
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
}
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
session, exists := TerminalSessions[id]
|
||||
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
|
||||
established_time := time.Now()
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
messageType, data, err := session.Browser.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if messageType == websocket.TextMessage {
|
||||
if session.Agent != nil && string(data[0:1]) == "{" {
|
||||
err = session.Agent.WriteMessage(websocket.TextMessage, data)
|
||||
} else if session.Agent != nil {
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
} else if session.Agent != nil {
|
||||
// 二进制消息,原样传递
|
||||
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
_, data, err := session.Agent.ReadMessage()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
if session.Browser != nil {
|
||||
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待错误或主动关闭
|
||||
<-errChan
|
||||
// 关闭连接
|
||||
if session.Agent != nil {
|
||||
session.Agent.Close()
|
||||
}
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
disconnect_time := time.Now()
|
||||
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package client
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
)
|
||||
|
||||
func EstablishConnection(c *gin.Context) {
|
||||
session_id := c.Query("id")
|
||||
session, exists := api.TerminalSessions[session_id]
|
||||
session, exists := TerminalSessions[session_id]
|
||||
if !exists || session == nil || session.Browser == nil {
|
||||
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
|
||||
return
|
||||
@@ -22,27 +21,27 @@ func EstablishConnection(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // TODO: 检查源站
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
api.TerminalSessionsMutex.Lock()
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
delete(api.TerminalSessions, session_id)
|
||||
api.TerminalSessionsMutex.Unlock()
|
||||
delete(TerminalSessions, session_id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
session.Agent = conn
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
delete(api.TerminalSessions, session_id)
|
||||
delete(TerminalSessions, session_id)
|
||||
// 通知 Browser 关闭终端连接
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go api.ForwardTerminal(session_id)
|
||||
go ForwardTerminal(session_id)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
)
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
session, exists := TerminalSessions[id]
|
||||
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
auditlog.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()
|
||||
auditlog.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func RequestTerminal(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
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: ws.CheckOrigin,
|
||||
}
|
||||
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被控端离线!\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...\n"))
|
||||
// 如果没有连接上,则关闭连接
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Agent == nil {
|
||||
if session.Browser != nil {
|
||||
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout\n"))
|
||||
session.Browser.Close()
|
||||
}
|
||||
conn.Close()
|
||||
delete(TerminalSessions, id)
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
//auditlog.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func GetClients(c *gin.Context) {
|
||||
// 升级到ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey, false)
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 初始化用户信息
|
||||
var (
|
||||
isLogin = false
|
||||
hiddenMap = map[string]bool{}
|
||||
session, _ = c.Cookie("session_token")
|
||||
)
|
||||
|
||||
// 登录状态检查
|
||||
_, err = accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 请求
|
||||
for {
|
||||
var resp struct {
|
||||
Online []string `json:"online"` // 已建立连接的客户端uuid列表
|
||||
Data map[string]common.Report `json:"data"` // 最后上报的数据
|
||||
}
|
||||
|
||||
resp.Online = []string{}
|
||||
resp.Data = map[string]common.Report{}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
//log.Println("Error reading message:", err)
|
||||
return
|
||||
}
|
||||
message := string(data)
|
||||
|
||||
uuID := ""
|
||||
if message != "get" { // 非请求全部内容
|
||||
if strings.HasPrefix(message, "get ") {
|
||||
uuID = strings.TrimSpace(strings.TrimPrefix(message, "get "))
|
||||
} else {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 在线客户端uuid列表(WebSocket 与非 WebSocket)
|
||||
for _, key := range ws.GetAllOnlineUUIDs() {
|
||||
if !isLogin && hiddenMap[key] {
|
||||
continue
|
||||
}
|
||||
if uuID != "" && key != uuID {
|
||||
continue
|
||||
}
|
||||
resp.Online = append(resp.Online, key)
|
||||
}
|
||||
|
||||
//过往节点数据信息
|
||||
for key, report := range ws.GetLatestReport() {
|
||||
if !isLogin && hiddenMap[key] {
|
||||
continue
|
||||
}
|
||||
if uuID != "" && key != uuID {
|
||||
continue
|
||||
}
|
||||
|
||||
report.UUID = "" // 不暴露 uuid
|
||||
if report.CPU.Usage == 0 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
resp.Data[key] = *report
|
||||
}
|
||||
|
||||
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -5,11 +5,13 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
Username string
|
||||
//Username string
|
||||
NewPassword string
|
||||
)
|
||||
|
||||
@@ -17,7 +19,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
Use: "chpasswd",
|
||||
Short: "Force change password",
|
||||
Long: `Force change password`,
|
||||
Example: `komari chpasswd -u <username> -p <password>`,
|
||||
Example: `komari chpasswd -p <password>`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if NewPassword == "" {
|
||||
cmd.Help()
|
||||
@@ -27,9 +29,10 @@ var ChpasswdCmd = &cobra.Command{
|
||||
cmd.Println("Database file does not exist.")
|
||||
return
|
||||
}
|
||||
|
||||
cmd.Println("Changing password for user:", Username)
|
||||
if err := accounts.ForceResetPassword(Username, NewPassword); err != nil {
|
||||
user := &models.User{}
|
||||
dbcore.GetDBInstance().Model(&models.User{}).First(user)
|
||||
cmd.Println("Changing password for user:", user.Username)
|
||||
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
@@ -45,7 +48,7 @@ var ChpasswdCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
//ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
|
||||
ChpasswdCmd.PersistentFlags().StringVarP(&NewPassword, "password", "p", "", "New password")
|
||||
RootCmd.AddCommand(ChpasswdCmd)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var Disable2FA = &cobra.Command{
|
||||
Use: "disable-2fa",
|
||||
Short: "Force disable 2FA",
|
||||
Long: `Force disable 2FA`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.User{}).Where("two_factor != ?", "").
|
||||
Update("two_factor", "").Error
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd.Println("2FA has been disabled.")
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(Disable2FA)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apiClient "github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
|
||||
"github.com/komari-monitor/komari/compat/nezha/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// [Deprecated] Use StartNezhaCompat instead.
|
||||
func StartNezhaCompatServer(addr string) error {
|
||||
boot := uint64(time.Now().Unix())
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &nezhaCompatServer{bootTime: boot}
|
||||
|
||||
// Interceptors for metadata auth presence (lightweight; validation is inside handlers too)
|
||||
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
return handler(srvIface, ss)
|
||||
}
|
||||
// Enable gRPC keepalive to tolerate long-idle streams
|
||||
gs := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(unary),
|
||||
grpc.StreamInterceptor(stream),
|
||||
// Keepalive: allow client pings and keep connections stable without app traffic
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
Time: 2 * time.Minute,
|
||||
Timeout: 20 * time.Second,
|
||||
}),
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: true,
|
||||
}),
|
||||
)
|
||||
proto.RegisterNezhaServiceServer(gs, srv)
|
||||
log.Printf("Nezha compat gRPC listening on %s", addr)
|
||||
return gs.Serve(lis)
|
||||
}
|
||||
|
||||
// ---- Manual start/stop support ----
|
||||
var (
|
||||
nezhaSrv *grpc.Server
|
||||
nezhaLis net.Listener
|
||||
nezhaOnceM sync.Mutex
|
||||
)
|
||||
|
||||
// StartNezhaCompat starts the Nezha compatible gRPC server asynchronously.
|
||||
// Returns error if already started.
|
||||
func StartNezhaCompat(addr string) error {
|
||||
nezhaOnceM.Lock()
|
||||
defer nezhaOnceM.Unlock()
|
||||
if nezhaSrv != nil {
|
||||
return errors.New("nezha compat server already started")
|
||||
}
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
boot := uint64(time.Now().Unix())
|
||||
sImpl := &nezhaCompatServer{bootTime: boot}
|
||||
|
||||
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
return handler(srvIface, ss)
|
||||
}
|
||||
gs := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(unary),
|
||||
grpc.StreamInterceptor(stream),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute, Timeout: 20 * time.Second}),
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
|
||||
)
|
||||
proto.RegisterNezhaServiceServer(gs, sImpl)
|
||||
nezhaSrv = gs
|
||||
nezhaLis = lis
|
||||
go func() {
|
||||
if err := gs.Serve(lis); err != nil {
|
||||
log.Printf("Nezha compat gRPC server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("Nezha compat gRPC started on %s", addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopNezhaCompat stops the server if running.
|
||||
func StopNezhaCompat() error {
|
||||
nezhaOnceM.Lock()
|
||||
defer nezhaOnceM.Unlock()
|
||||
if nezhaSrv == nil {
|
||||
return errors.New("nezha compat server not running")
|
||||
}
|
||||
// 强制立即断开所有连接与流,不等待在途 RPC 完成。
|
||||
nezhaSrv.Stop()
|
||||
// Listener close (Serve already returns after GracefulStop, but close to be explicit)
|
||||
if nezhaLis != nil {
|
||||
_ = nezhaLis.Close()
|
||||
}
|
||||
nezhaSrv = nil
|
||||
nezhaLis = nil
|
||||
log.Printf("Nezha compat gRPC stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
type nezhaCompatServer struct {
|
||||
proto.UnimplementedNezhaServiceServer
|
||||
bootTime uint64
|
||||
}
|
||||
|
||||
// authentication helpers
|
||||
func getAuth(ctx context.Context) (uuid string, secret string, err error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return "", "", errors.New("missing metadata")
|
||||
}
|
||||
getFirst := func(key string) string {
|
||||
vals := md.Get(key)
|
||||
if len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
uuid = getFirst("client_uuid")
|
||||
secret = getFirst("client_secret")
|
||||
if uuid == "" || secret == "" {
|
||||
return "", "", errors.New("unauthorized: missing client_uuid/client_secret")
|
||||
}
|
||||
return uuid, secret, nil
|
||||
}
|
||||
|
||||
// ReportSystemInfo: upsert static host info
|
||||
func (s *nezhaCompatServer) ReportSystemInfo(ctx context.Context, host *proto.Host) (*proto.Receipt, error) {
|
||||
uuid, secret, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertClientFromHost(uuid, secret, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.Receipt{Proced: true}, nil
|
||||
}
|
||||
|
||||
// ReportSystemInfo2: same as above but returns dashboard boot time
|
||||
func (s *nezhaCompatServer) ReportSystemInfo2(ctx context.Context, host *proto.Host) (*proto.Uint64Receipt, error) {
|
||||
uuid, secret, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertClientFromHost(uuid, secret, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.Uint64Receipt{Data: s.bootTime}, nil
|
||||
}
|
||||
|
||||
// ReportSystemState: ingest time-series records
|
||||
func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSystemStateServer) error {
|
||||
ctx := stream.Context()
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// presence start
|
||||
connID := time.Now().UnixNano()
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
go notifier.OnlineNotification(uuid, connID)
|
||||
defer func() {
|
||||
ws.SetPresence(uuid, connID, false)
|
||||
notifier.OfflineNotification(uuid, connID)
|
||||
}()
|
||||
for {
|
||||
st, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// refresh presence TTL on every frame
|
||||
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
if err := ingestState(uuid, st); err != nil {
|
||||
// still ack to avoid client stuck; log error
|
||||
log.Printf("Nezha ingest state error: %v", err)
|
||||
}
|
||||
if err := stream.Send(&proto.Receipt{Proced: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestTask: do not dispatch tasks, just drain results to avoid Unimplemented
|
||||
func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskServer) error {
|
||||
ctx := stream.Context()
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
connID := time.Now().UnixNano()
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
defer ws.SetPresence(uuid, connID, false)
|
||||
// receive results in background
|
||||
recvErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
_, rerr := stream.Recv()
|
||||
if rerr == io.EOF {
|
||||
recvErr <- nil
|
||||
return
|
||||
}
|
||||
if rerr != nil {
|
||||
recvErr <- rerr
|
||||
return
|
||||
}
|
||||
// refresh presence TTL when result received
|
||||
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
}
|
||||
}()
|
||||
// send heartbeat tasks periodically
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-recvErr:
|
||||
return err
|
||||
case <-ticker.C:
|
||||
if err := stream.Send(&proto.Task{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unimplemented methods intentionally left as default (IOStream, ReportGeoIP)
|
||||
|
||||
// upsertClientFromHost maps Host into models.Client and upserts by UUID
|
||||
func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
now := models.FromTime(time.Now())
|
||||
// token guard: if existing record has different token, reject
|
||||
var exist models.Client
|
||||
if err := db.Where("uuid = ?", uuid).First(&exist).Error; err == nil {
|
||||
if exist.Token != "" && exist.Token != secret {
|
||||
return errors.New("unauthorized: token mismatch")
|
||||
}
|
||||
}
|
||||
cpuName := ""
|
||||
if len(h.Cpu) > 0 {
|
||||
cpuName = h.Cpu[0]
|
||||
}
|
||||
gpuName := strings.Join(h.Gpu, "; ")
|
||||
osName := h.Platform
|
||||
if h.PlatformVersion != "" {
|
||||
osName = h.Platform + " " + h.PlatformVersion
|
||||
}
|
||||
// clamp uint64 to int64 safely
|
||||
clamp := func(v uint64) int64 {
|
||||
if v > uint64(math.MaxInt64) {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(v)
|
||||
}
|
||||
c := models.Client{
|
||||
UUID: uuid,
|
||||
Token: secret,
|
||||
Name: "nezha_" + uuid[0:8],
|
||||
CpuName: cpuName,
|
||||
Arch: h.Arch,
|
||||
CpuCores: len(h.Cpu),
|
||||
OS: osName,
|
||||
KernelVersion: h.PlatformVersion,
|
||||
Virtualization: h.Virtualization,
|
||||
GpuName: gpuName,
|
||||
MemTotal: clamp(h.MemTotal),
|
||||
SwapTotal: clamp(h.SwapTotal),
|
||||
DiskTotal: clamp(h.DiskTotal),
|
||||
Version: h.Version,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
// Upsert by UUID; don't override existing Token if already set
|
||||
updates := map[string]interface{}{
|
||||
"cpu_name": c.CpuName,
|
||||
"arch": c.Arch,
|
||||
"cpu_cores": c.CpuCores,
|
||||
"os": c.OS,
|
||||
"kernel_version": c.KernelVersion,
|
||||
"virtualization": c.Virtualization,
|
||||
"gpu_name": c.GpuName,
|
||||
"mem_total": c.MemTotal,
|
||||
"swap_total": c.SwapTotal,
|
||||
"disk_total": c.DiskTotal,
|
||||
"version": c.Version,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(&c).Error
|
||||
}
|
||||
|
||||
// ingestState maps Nezha State into common.Report then saves a Record
|
||||
func ingestState(uuid string, st *proto.State) error {
|
||||
// we may need totals from client
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
// If missing, create with minimal defaults to avoid failing ingestion
|
||||
client = models.Client{UUID: uuid, Token: "", Name: "nezha_" + uuid[0:8]}
|
||||
auditlog.EventLog("info", "auto created client "+client.Name)
|
||||
_ = db.Create(&client).Error
|
||||
}
|
||||
rep := common.Report{
|
||||
CPU: common.CPUReport{Usage: st.Cpu},
|
||||
Ram: common.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
|
||||
Swap: common.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
|
||||
Load: common.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
|
||||
Disk: common.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
|
||||
Network: common.NetworkReport{
|
||||
Up: int64(st.NetOutSpeed),
|
||||
Down: int64(st.NetInSpeed),
|
||||
TotalUp: int64(st.NetOutTransfer),
|
||||
TotalDown: int64(st.NetInTransfer),
|
||||
},
|
||||
Uptime: int64(st.Uptime),
|
||||
Process: int(st.ProcessCount),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
// 更新实时缓存供前端使用
|
||||
ws.SetLatestReport(uuid, &rep)
|
||||
// 写入内存缓存,入库交由定时聚合任务处理
|
||||
return apiClient.SaveClientReport(uuid, rep)
|
||||
}
|
||||
|
||||
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
|
||||
func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*proto.GeoIP, error) {
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
var iso string
|
||||
if in != nil && in.Ip != nil {
|
||||
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
|
||||
updates["ipv4"] = v4
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v4); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
|
||||
updates["ipv6"] = v6
|
||||
if iso == "" { // 优先使用 v4 的国家码
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v6); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if iso != "" {
|
||||
// UI 使用旗帜 emoji
|
||||
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
}
|
||||
// 回写 GeoIP(包含国家码与面板启动时间)
|
||||
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
|
||||
return resp, nil
|
||||
}
|
||||
+8
-8
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
@@ -19,13 +19,13 @@ func getEnv(key, defaultValue string) string {
|
||||
|
||||
// 从环境变量获取默认值
|
||||
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")
|
||||
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{
|
||||
|
||||
+350
-172
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,21 +13,34 @@ import (
|
||||
"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/jsonRpc"
|
||||
public_api "github.com/komari-monitor/komari/api/public"
|
||||
"github.com/komari-monitor/komari/api/terminal"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"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/ws"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -39,176 +53,322 @@ var ServerCmd = &cobra.Command{
|
||||
Short: "Start the server",
|
||||
Long: `Start the server`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
InitDatabase()
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 动态 CORS 中间件
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "43200") // 12 hours
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
|
||||
r.POST("/api/login", api.Login)
|
||||
r.GET("/api/me", api.GetMe)
|
||||
r.GET("/api/clients", ws.GetClients)
|
||||
r.GET("/api/nodes", api.GetNodesInformation)
|
||||
r.GET("/api/public", api.GetPublicSettings)
|
||||
r.GET("/api/oauth", api.OAuth)
|
||||
r.GET("/api/oauth_callback", api.OAuthCallback)
|
||||
r.GET("/api/logout", api.Logout)
|
||||
r.GET("/api/version", api.GetVersion)
|
||||
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
|
||||
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", client.EstablishConnection)
|
||||
tokenAuthrized.POST("/task/result", client.TaskResult)
|
||||
}
|
||||
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
// test
|
||||
testGroup := adminAuthrized.Group("/test")
|
||||
{
|
||||
testGroup.GET("/telegram", test.TestTelegram)
|
||||
testGroup.GET("/geoip", test.TestGeoIp)
|
||||
}
|
||||
// update
|
||||
updateGroup := adminAuthrized.Group("/update")
|
||||
{
|
||||
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
|
||||
updateGroup.POST("/user", update.UpdateUser)
|
||||
updateGroup.PUT("/favicon", update.UploadFavicon)
|
||||
updateGroup.POST("/favicon", update.DeleteFavicon)
|
||||
}
|
||||
// tasks
|
||||
taskGroup := adminAuthrized.Group("/task")
|
||||
{
|
||||
taskGroup.GET("/all", admin.GetTasks)
|
||||
taskGroup.POST("/exec", admin.Exec)
|
||||
taskGroup.GET("/:task_id", admin.GetTaskById)
|
||||
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
|
||||
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
|
||||
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
|
||||
}
|
||||
// settings
|
||||
adminAuthrized.GET("/settings", admin.GetSettings)
|
||||
adminAuthrized.POST("/settings", admin.EditSettings)
|
||||
// clients
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
{
|
||||
clientGroup.POST("/add", admin.AddClient)
|
||||
clientGroup.GET("/list", admin.ListClients)
|
||||
clientGroup.GET("/:uuid", admin.GetClient)
|
||||
clientGroup.POST("/:uuid/edit", admin.EditClient)
|
||||
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
|
||||
clientGroup.GET("/:uuid/token", admin.GetClientToken)
|
||||
clientGroup.POST("/order", admin.OrderWeight)
|
||||
// client terminal
|
||||
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
// 静态文件服务
|
||||
public.UpdateIndex(conf)
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
public.UpdateIndex(event.New)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
OnFatal(err)
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
OnShutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
RunServer()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 从环境变量获取监听地址
|
||||
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
func RunServer() {
|
||||
// #region 初始化
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
log.Fatalf("Failed to create theme directory: %v", err)
|
||||
}
|
||||
InitDatabase()
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.GetManyAs[config.Legacy]()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
// oidcInit
|
||||
go oauth.Initialize()
|
||||
|
||||
if conf.NezhaCompatEnabled {
|
||||
go func() {
|
||||
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
|
||||
log.Printf("Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
|
||||
if t == "" || t == "none" {
|
||||
t = "github"
|
||||
}
|
||||
oidcProvider, err := database.GetOidcConfigByName(t)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
} else {
|
||||
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
|
||||
}
|
||||
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
|
||||
if err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
|
||||
if t {
|
||||
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
|
||||
if err := StartNezhaCompat(l); err != nil {
|
||||
log.Printf("start Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
|
||||
}
|
||||
} else {
|
||||
if err := StopNezhaCompat(); err != nil {
|
||||
log.Printf("stop Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
// 初始化 cloudflared
|
||||
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
|
||||
log.Printf("failed to auto start cloudflared: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
// 动态 CORS 中间件
|
||||
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
|
||||
DynamicCorsEnabled = t
|
||||
}
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
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(api.IdentityMiddleware())
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
|
||||
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", public_api.Login)
|
||||
r.GET("/api/me", public_api.GetMe)
|
||||
r.GET("/api/clients", api.GetClients)
|
||||
r.GET("/api/nodes", public_api.GetNodesInformation)
|
||||
r.GET("/api/public", public_api.GetPublicSettings)
|
||||
r.GET("/api/oauth", public_api.OAuth)
|
||||
r.GET("/api/oauth_callback", public_api.OAuthCallback)
|
||||
r.GET("/api/logout", public_api.Logout)
|
||||
r.GET("/api/version", public_api.GetVersion)
|
||||
r.GET("/api/recent/:uuid", public_api.GetClientRecentRecords)
|
||||
|
||||
r.GET("/api/records/load", public_api.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", public_api.GetPingRecords)
|
||||
r.GET("/api/task/ping", public_api.GetPublicPingTasks)
|
||||
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", api.RequireRole(api.RoleAdmin, api.RoleClient))
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", terminal.EstablishConnection)
|
||||
tokenAuthrized.POST("/task/result", client.TaskResult)
|
||||
tokenAuthrized.GET("/ping/tasks", client.GetPingTasks)
|
||||
tokenAuthrized.POST("/ping/result", client.UploadPingResult)
|
||||
}
|
||||
// #region 管理员
|
||||
adminAuthrized := r.Group("/api/admin", api.RequireRole(api.RoleAdmin))
|
||||
{
|
||||
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
|
||||
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
|
||||
// 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
|
||||
settingsGroup := adminAuthrized.Group("/settings")
|
||||
{
|
||||
settingsGroup.GET("/", admin.GetSettings)
|
||||
settingsGroup.POST("/", admin.EditSettings)
|
||||
settingsGroup.POST("/oidc", admin.SetOidcProvider)
|
||||
settingsGroup.GET("/oidc", admin.GetOidcProvider)
|
||||
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
|
||||
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
|
||||
settingsGroup.GET("/cloudflared", admin.GetCloudflaredStatus)
|
||||
settingsGroup.POST("/cloudflared/start", admin.StartCloudflared)
|
||||
settingsGroup.POST("/cloudflared/stop", admin.StopCloudflared)
|
||||
settingsGroup.POST("/cloudflared/remove-token", admin.RemoveCloudflaredToken)
|
||||
}
|
||||
// themes
|
||||
themeGroup := adminAuthrized.Group("/theme")
|
||||
{
|
||||
themeGroup.PUT("/upload", admin.UploadTheme)
|
||||
themeGroup.GET("/list", admin.ListThemes)
|
||||
themeGroup.POST("/delete", admin.DeleteTheme)
|
||||
themeGroup.GET("/set", admin.SetTheme)
|
||||
themeGroup.POST("/update", admin.UpdateTheme)
|
||||
themeGroup.POST("/import", admin.ImportTheme)
|
||||
themeGroup.POST("/settings", admin.UpdateThemeSettings)
|
||||
}
|
||||
// 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", terminal.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)
|
||||
pingTaskGroup.POST("/order", admin.OrderPingTask)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
log.Printf("Starting server on %s ...", flags.Listen)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func InitDatabase() {
|
||||
// // 打印数据库类型和连接信息
|
||||
// if flags.DatabaseType == "mysql" {
|
||||
@@ -232,29 +392,47 @@ func InitDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
if err := tasks.MigrateAllClientsExpansion(); err != nil {
|
||||
log.Println("Failed to migrate ping task all_clients expansion:", err)
|
||||
}
|
||||
tasks.ReloadPingSchedule()
|
||||
d_notification.ReloadLoadNotificationSchedule()
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
ticker1 := time.NewTicker(60 * time.Second)
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
go notifier.CheckExpireScheduledWork()
|
||||
for {
|
||||
cfg, _ := config.GetManyAs[config.Legacy]()
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
logOperation.RemoveOldLogs()
|
||||
case <-ticker1.C:
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
auditlog.RemoveOldLogs()
|
||||
accounts.RemoveExpiredSessions()
|
||||
case <-minute.C:
|
||||
api.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
go notifier.CheckTraffic()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
logOperation.Log("", "", "server is shutting down", "info")
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
@@ -69,6 +69,7 @@ type Report struct {
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
@@ -83,6 +84,23 @@ type CPUReport struct {
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// GPUDetailReport 详细GPU信息报告
|
||||
type GPUDetailReport struct {
|
||||
Count int `json:"count"` // GPU数量
|
||||
AverageUsage float64 `json:"average_usage"` // 平均使用率
|
||||
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
|
||||
}
|
||||
|
||||
// GPUDeviceInfo 单个GPU设备信息
|
||||
type GPUDeviceInfo struct {
|
||||
Name string `json:"name"` // GPU型号名称
|
||||
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
|
||||
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
|
||||
Utilization float64 `json:"utilization"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// 保持向后兼容的旧GPUReport结构
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
syntax = "proto3";
|
||||
package proto;
|
||||
option go_package = "github.com/komari-monitor/komari/compat/nezha/proto";
|
||||
|
||||
service NezhaService {
|
||||
rpc ReportSystemState(stream State) returns (stream Receipt) {}
|
||||
rpc ReportSystemInfo(Host) returns (Receipt) {}
|
||||
rpc RequestTask(stream TaskResult) returns (stream Task) {}
|
||||
rpc IOStream(stream IOStreamData) returns (stream IOStreamData) {}
|
||||
rpc ReportGeoIP(GeoIP) returns (GeoIP) {}
|
||||
rpc ReportSystemInfo2(Host) returns (Uint64Receipt) {}
|
||||
}
|
||||
|
||||
message Host {
|
||||
string platform = 1;
|
||||
string platform_version = 2;
|
||||
repeated string cpu = 3;
|
||||
uint64 mem_total = 4;
|
||||
uint64 disk_total = 5;
|
||||
uint64 swap_total = 6;
|
||||
string arch = 7;
|
||||
string virtualization = 8;
|
||||
uint64 boot_time = 9;
|
||||
string version = 10;
|
||||
repeated string gpu = 11;
|
||||
}
|
||||
|
||||
message State {
|
||||
double cpu = 1;
|
||||
uint64 mem_used = 2;
|
||||
uint64 swap_used = 3;
|
||||
uint64 disk_used = 4;
|
||||
uint64 net_in_transfer = 5;
|
||||
uint64 net_out_transfer = 6;
|
||||
uint64 net_in_speed = 7;
|
||||
uint64 net_out_speed = 8;
|
||||
uint64 uptime = 9;
|
||||
double load1 = 10;
|
||||
double load5 = 11;
|
||||
double load15 = 12;
|
||||
uint64 tcp_conn_count = 13;
|
||||
uint64 udp_conn_count = 14;
|
||||
uint64 process_count = 15;
|
||||
repeated State_SensorTemperature temperatures = 16;
|
||||
repeated double gpu = 17;
|
||||
}
|
||||
|
||||
message State_SensorTemperature {
|
||||
string name = 1;
|
||||
double temperature = 2;
|
||||
}
|
||||
|
||||
message Task {
|
||||
uint64 id = 1;
|
||||
uint64 type = 2;
|
||||
string data = 3;
|
||||
}
|
||||
|
||||
message TaskResult {
|
||||
uint64 id = 1;
|
||||
uint64 type = 2;
|
||||
float delay = 3;
|
||||
string data = 4;
|
||||
bool successful = 5;
|
||||
}
|
||||
|
||||
message Receipt { bool proced = 1; }
|
||||
|
||||
message Uint64Receipt { uint64 data = 1; }
|
||||
|
||||
message IOStreamData { bytes data = 1; }
|
||||
|
||||
message GeoIP {
|
||||
bool use6 = 1;
|
||||
IP ip = 2;
|
||||
string country_code = 3;
|
||||
uint64 dashboard_boot_time = 4;
|
||||
}
|
||||
|
||||
message IP {
|
||||
string ipv4 = 1;
|
||||
string ipv6 = 2;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,374 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
// NezhaServiceClient is the client API for NezhaService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type NezhaServiceClient interface {
|
||||
ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error)
|
||||
ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error)
|
||||
RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error)
|
||||
IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error)
|
||||
ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error)
|
||||
ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error)
|
||||
}
|
||||
|
||||
type nezhaServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewNezhaServiceClient(cc grpc.ClientConnInterface) NezhaServiceClient {
|
||||
return &nezhaServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[0], "/proto.NezhaService/ReportSystemState", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceReportSystemStateClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_ReportSystemStateClient interface {
|
||||
Send(*State) error
|
||||
Recv() (*Receipt, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceReportSystemStateClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateClient) Send(m *State) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateClient) Recv() (*Receipt, error) {
|
||||
m := new(Receipt)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error) {
|
||||
out := new(Receipt)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[1], "/proto.NezhaService/RequestTask", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceRequestTaskClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_RequestTaskClient interface {
|
||||
Send(*TaskResult) error
|
||||
Recv() (*Task, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceRequestTaskClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskClient) Send(m *TaskResult) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskClient) Recv() (*Task, error) {
|
||||
m := new(Task)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[2], "/proto.NezhaService/IOStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceIOStreamClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_IOStreamClient interface {
|
||||
Send(*IOStreamData) error
|
||||
Recv() (*IOStreamData, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceIOStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamClient) Send(m *IOStreamData) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamClient) Recv() (*IOStreamData, error) {
|
||||
m := new(IOStreamData)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error) {
|
||||
out := new(GeoIP)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportGeoIP", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error) {
|
||||
out := new(Uint64Receipt)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo2", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NezhaServiceServer is the server API for NezhaService service.
|
||||
// All implementations must embed UnimplementedNezhaServiceServer
|
||||
// for forward compatibility
|
||||
type NezhaServiceServer interface {
|
||||
ReportSystemState(NezhaService_ReportSystemStateServer) error
|
||||
ReportSystemInfo(context.Context, *Host) (*Receipt, error)
|
||||
RequestTask(NezhaService_RequestTaskServer) error
|
||||
IOStream(NezhaService_IOStreamServer) error
|
||||
ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error)
|
||||
ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error)
|
||||
mustEmbedUnimplementedNezhaServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedNezhaServiceServer must be embedded to have forward compatible implementations.
|
||||
type UnimplementedNezhaServiceServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemState(NezhaService_ReportSystemStateServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ReportSystemState not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemInfo(context.Context, *Host) (*Receipt, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) RequestTask(NezhaService_RequestTaskServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method RequestTask not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) IOStream(NezhaService_IOStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method IOStream not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportGeoIP not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo2 not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) mustEmbedUnimplementedNezhaServiceServer() {}
|
||||
|
||||
// UnsafeNezhaServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to NezhaServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeNezhaServiceServer interface {
|
||||
mustEmbedUnimplementedNezhaServiceServer()
|
||||
}
|
||||
|
||||
func RegisterNezhaServiceServer(s *grpc.Server, srv NezhaServiceServer) {
|
||||
s.RegisterService(&_NezhaService_serviceDesc, srv)
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemState_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).ReportSystemState(&nezhaServiceReportSystemStateServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_ReportSystemStateServer interface {
|
||||
Send(*Receipt) error
|
||||
Recv() (*State, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceReportSystemStateServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateServer) Send(m *Receipt) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateServer) Recv() (*State, error) {
|
||||
m := new(State)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Host)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportSystemInfo",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, req.(*Host))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NezhaService_RequestTask_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).RequestTask(&nezhaServiceRequestTaskServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_RequestTaskServer interface {
|
||||
Send(*Task) error
|
||||
Recv() (*TaskResult, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceRequestTaskServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskServer) Send(m *Task) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskServer) Recv() (*TaskResult, error) {
|
||||
m := new(TaskResult)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_IOStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).IOStream(&nezhaServiceIOStreamServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_IOStreamServer interface {
|
||||
Send(*IOStreamData) error
|
||||
Recv() (*IOStreamData, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceIOStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamServer) Send(m *IOStreamData) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamServer) Recv() (*IOStreamData, error) {
|
||||
m := new(IOStreamData)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_ReportGeoIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GeoIP)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportGeoIP(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportGeoIP",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportGeoIP(ctx, req.(*GeoIP))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemInfo2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Host)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportSystemInfo2",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, req.(*Host))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
var _NezhaService_serviceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "proto.NezhaService",
|
||||
HandlerType: (*NezhaServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ReportSystemInfo",
|
||||
Handler: _NezhaService_ReportSystemInfo_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportGeoIP",
|
||||
Handler: _NezhaService_ReportGeoIP_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportSystemInfo2",
|
||||
Handler: _NezhaService_ReportSystemInfo2_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "ReportSystemState",
|
||||
Handler: _NezhaService_ReportSystemState_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "RequestTask",
|
||||
Handler: _NezhaService_RequestTask_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "IOStream",
|
||||
Handler: _NezhaService_IOStream_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "compat/nezha/nezha.proto",
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
Key string `gorm:"primaryKey;column:key;type:text"`
|
||||
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
|
||||
}
|
||||
|
||||
func (ConfigItem) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
SetDb = func(gdb *gorm.DB) {
|
||||
db = gdb
|
||||
migrateInPlace()
|
||||
}
|
||||
)
|
||||
|
||||
func migrateInPlace() {
|
||||
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
|
||||
slog.Info("[>1.1.4] Moving legacy config data...")
|
||||
|
||||
var oldData Legacy
|
||||
if err := db.Order("id desc").First(&oldData).Error; err != nil {
|
||||
db.Migrator().DropTable("configs")
|
||||
} else {
|
||||
var newRows []ConfigItem
|
||||
val := reflect.ValueOf(oldData)
|
||||
typ := reflect.TypeOf(oldData)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
key := strings.Split(tag, ",")[0]
|
||||
|
||||
// 过滤 id 和无用字段
|
||||
if key == "" || key == "-" || key == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
valInterface := val.Field(i).Interface()
|
||||
jsonBytes, _ := json.Marshal(valInterface)
|
||||
|
||||
newRows = append(newRows, ConfigItem{
|
||||
Key: key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newRows) > 0 {
|
||||
return tx.Create(&newRows).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.AutoMigrate(&ConfigItem{})
|
||||
}
|
||||
|
||||
// Get 获取原始值 (反序列化为 interface{})
|
||||
func Get(key string, defaul ...any) (any, error) {
|
||||
var item ConfigItem
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v := defaul[0]
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
|
||||
func GetAs[T any](key string, defaul ...any) (T, error) {
|
||||
var t T
|
||||
var item ConfigItem
|
||||
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
// 尝试直接类型断言
|
||||
if v, ok := defaul[0].(T); ok {
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
// 尝试类型转换
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
if err := convertAndSet(defaul[0], val); err != nil {
|
||||
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
|
||||
}
|
||||
err = Set(key, t)
|
||||
return t, err
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// 先尝试直接反序列化
|
||||
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
|
||||
// 尝试通用解析后转换
|
||||
var generic any
|
||||
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
|
||||
return t, err
|
||||
}
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
if err := convertAndSet(generic, val); err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
|
||||
// 如果 defaultValue 为 nil,则数据库不存在时不写入
|
||||
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
|
||||
func GetMany(keys map[string]any) (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
keyList := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
keyList = append(keyList, k)
|
||||
}
|
||||
if len(keyList) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
foundKeys[item.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 收集需要写入数据库的默认值
|
||||
var toInsert []ConfigItem
|
||||
for k, def := range keys {
|
||||
if _, found := foundKeys[k]; !found {
|
||||
if def != nil {
|
||||
result[k] = def
|
||||
// 序列化后加入待写入列表
|
||||
jsonBytes, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
slog.Warn("marshal default value failed", "key", k, "error", err)
|
||||
continue
|
||||
}
|
||||
toInsert = append(toInsert, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量写入默认值到数据库
|
||||
if len(toInsert) > 0 {
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&toInsert).Error; err != nil {
|
||||
slog.Warn("batch insert default config failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
|
||||
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
|
||||
// 没有 default tag 的字段使用零值,不写入数据库
|
||||
func GetManyAs[T any]() (*T, error) {
|
||||
var t T
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
type fieldInfo struct {
|
||||
index int
|
||||
key string
|
||||
hasDefault bool
|
||||
defaultVal string
|
||||
}
|
||||
|
||||
fields := make([]fieldInfo, 0)
|
||||
keys := make([]string, 0)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
// 解析 json tag,处理 "key,omitempty" 格式
|
||||
key := strings.Split(jsonTag, ",")[0]
|
||||
if key == "" || key == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
defaultTag := field.Tag.Get("default")
|
||||
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
|
||||
// 检查是否显式定义了 default tag (即使值为空)
|
||||
_, hasDefault = field.Tag.Lookup("default")
|
||||
|
||||
fields = append(fields, fieldInfo{
|
||||
index: i,
|
||||
key: key,
|
||||
hasDefault: hasDefault,
|
||||
defaultVal: defaultTag,
|
||||
})
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
var items []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 建立数据库中存在的 key 映射
|
||||
foundItems := make(map[string]string) // key -> value
|
||||
for _, item := range items {
|
||||
foundItems[item.Key] = item.Value
|
||||
}
|
||||
|
||||
// 需要写入数据库的新配置项
|
||||
var toInsert []ConfigItem
|
||||
|
||||
for _, fi := range fields {
|
||||
fieldVal := val.Field(fi.index)
|
||||
if !fieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
if dbValue, found := foundItems[fi.key]; found {
|
||||
// 数据库中存在,使用数据库值
|
||||
if err := unmarshalToField(dbValue, fieldVal); err != nil {
|
||||
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
|
||||
}
|
||||
} else if fi.hasDefault {
|
||||
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
|
||||
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
|
||||
slog.Warn("parse default value failed", "key", fi.key, "error", err)
|
||||
continue
|
||||
}
|
||||
// 序列化后写入数据库
|
||||
jsonBytes, err := json.Marshal(fieldVal.Interface())
|
||||
if err != nil {
|
||||
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
|
||||
continue
|
||||
}
|
||||
toInsert = append(toInsert, ConfigItem{
|
||||
Key: fi.key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
|
||||
}
|
||||
|
||||
// 批量写入默认值到数据库
|
||||
if len(toInsert) > 0 {
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&toInsert).Error; err != nil {
|
||||
slog.Warn("batch insert default config failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
|
||||
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
|
||||
// 尝试通用解析后转换
|
||||
var generic any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
|
||||
return err
|
||||
}
|
||||
return convertAndSet(generic, fieldVal)
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDefaultToField 解析 default tag 值到字段
|
||||
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
|
||||
kind := fieldVal.Kind()
|
||||
|
||||
switch kind {
|
||||
case reflect.String:
|
||||
fieldVal.SetString(defaultVal)
|
||||
case reflect.Bool:
|
||||
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
var v int64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
|
||||
// 尝试解析浮点数后转换
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
|
||||
return err
|
||||
}
|
||||
v = int64(f)
|
||||
}
|
||||
}
|
||||
fieldVal.SetInt(v)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
var v uint64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
|
||||
return err
|
||||
}
|
||||
v = uint64(f)
|
||||
}
|
||||
}
|
||||
fieldVal.SetUint(v)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
var v float64
|
||||
if defaultVal != "" {
|
||||
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fieldVal.SetFloat(v)
|
||||
default:
|
||||
// 对于复杂类型,尝试 JSON 解析
|
||||
if defaultVal == "" {
|
||||
return nil // 保持零值
|
||||
}
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertAndSet 通用类型转换并设置字段值
|
||||
func convertAndSet(val any, fieldVal reflect.Value) error {
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetType := fieldVal.Type()
|
||||
v := reflect.ValueOf(val)
|
||||
|
||||
// 直接类型匹配
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
fieldVal.Set(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 类型可转换
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
fieldVal.Set(v.Convert(targetType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
|
||||
if f, ok := val.(float64); ok {
|
||||
switch fieldVal.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
fieldVal.SetInt(int64(f))
|
||||
return nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
fieldVal.SetUint(uint64(f))
|
||||
return nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
fieldVal.SetFloat(f)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 回环转换
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := reflect.New(targetType).Interface()
|
||||
if err := json.Unmarshal(b, target); err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAll() (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Set 设置单个配置
|
||||
func Set(key string, value any) error {
|
||||
oldVal := map[string]any{}
|
||||
{
|
||||
var oldItem ConfigItem
|
||||
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
|
||||
oldVal[key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := ConfigItem{
|
||||
Key: key,
|
||||
Value: string(bytes),
|
||||
}
|
||||
|
||||
err = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newVal := map[string]any{key: value}
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMany 将结构体保存为多个配置项
|
||||
func SetManyAs[T any](config T) error {
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
var items []ConfigItem
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
tag := fieldType.Tag.Get("json")
|
||||
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := val.Field(i).Interface()
|
||||
|
||||
bytes, err := json.Marshal(fieldValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
|
||||
}
|
||||
|
||||
items = append(items, ConfigItem{
|
||||
Key: tag,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetMany(cst map[string]any) error {
|
||||
var items []ConfigItem
|
||||
for k, v := range cst {
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key %s failed: %w", k, err)
|
||||
}
|
||||
items = append(items, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigEvent struct {
|
||||
Old map[string]any // Old models.Config
|
||||
New map[string]any // New models.Config
|
||||
}
|
||||
|
||||
func (e ConfigEvent) IsChanged(key string) bool {
|
||||
oldVal, oldOk := e.Old[key]
|
||||
newVal, newOk := e.New[key]
|
||||
if !oldOk && !newOk {
|
||||
return false
|
||||
}
|
||||
if oldOk != newOk {
|
||||
return true
|
||||
}
|
||||
return !reflect.DeepEqual(oldVal, newVal)
|
||||
}
|
||||
|
||||
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
|
||||
changed := e.IsChanged(key)
|
||||
var zero T
|
||||
|
||||
val, ok := e.New[key]
|
||||
if !ok {
|
||||
val, ok = e.Old[key]
|
||||
if !ok {
|
||||
return changed, zero
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// Fast path: direct assertion.
|
||||
if cast, ok := val.(T); ok {
|
||||
return changed, cast
|
||||
}
|
||||
|
||||
// Try reflection-based conversion (covers numeric conversions, etc.).
|
||||
targetType := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v := reflect.ValueOf(val)
|
||||
if v.IsValid() {
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
return changed, v.Interface().(T)
|
||||
}
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
converted := v.Convert(targetType)
|
||||
return changed, converted.Interface().(T)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
|
||||
if b, err := json.Marshal(val); err == nil {
|
||||
var out T
|
||||
if err := json.Unmarshal(b, &out); err == nil {
|
||||
return changed, out
|
||||
}
|
||||
}
|
||||
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal map[string]any) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
for _, sub := range subscribers {
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Legacy struct {
|
||||
ID uint `json:"id,omitempty"` // 1
|
||||
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
|
||||
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
|
||||
AllowCors bool `json:"allow_cors" default:"false"` // 是否允许跨域,默认 false
|
||||
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" default:"false"`
|
||||
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
|
||||
// OAuth 配置
|
||||
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
|
||||
OAuthProvider string `json:"o_auth_provider" default:"github"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
|
||||
CloudflareTunnelToken string `json:"cloudflare_tunnel_token" default:""`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" default:""`
|
||||
CustomBody string `json:"custom_body" default:""`
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" default:"none"`
|
||||
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
|
||||
// Record
|
||||
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
|
||||
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
AllowCorsKey = "allow_cors"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
BaseScriptsURLKey = "base_scripts_url"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
NezhaCompatEnabledKey = "nezha_compat_enabled"
|
||||
NezhaCompatListenKey = "nezha_compat_listen"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CloudflareTunnelTokenKey = "cloudflare_tunnel_token"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
NotificationEnabledKey = "notification_enabled"
|
||||
NotificationMethodKey = "notification_method"
|
||||
NotificationTemplateKey = "notification_template"
|
||||
ExpireNotificationEnabledKey = "expire_notification_enabled"
|
||||
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
RecordEnabledKey = "record_enabled"
|
||||
RecordPreserveTimeKey = "record_preserve_time"
|
||||
PingRecordPreserveTimeKey = "ping_record_preserve_time"
|
||||
UpdatedAtKey = "updated_at"
|
||||
)
|
||||
|
||||
func (Legacy) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
// Decrepted
|
||||
/*
|
||||
func Update(cst map[string]interface{}) error {
|
||||
oldConfig, _ := GetManyAs[Legacy]()
|
||||
// Proceed with update
|
||||
cst["updated_at"] = time.Now().Unix()
|
||||
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)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
*/
|
||||
@@ -100,8 +100,8 @@ func CreateDefaultAdminAccount() (username, passwd string, err error) {
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
SSOID: "",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
|
||||
@@ -2,11 +2,17 @@ package accounts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// GetAllSessions 获取所有会话
|
||||
@@ -27,12 +33,29 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: time.Now().Add(time.Duration(expires) * time.Second),
|
||||
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: time.Now(),
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
}
|
||||
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(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
@@ -50,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().After(sessionRecord.Expires) {
|
||||
if time.Now().After(sessionRecord.Expires.ToTime()) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -110,3 +133,12 @@ func UpdateLatest(session, useragent, ip string) error {
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package logOperation
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"log"
|
||||
@@ -16,11 +16,15 @@ func Log(ip, uuid, message, msgType string) {
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: now,
|
||||
Time: models.FromTime(now),
|
||||
}
|
||||
db.Create(logEntry)
|
||||
}
|
||||
|
||||
func EventLog(eventType, message string) {
|
||||
Log("", "", message, eventType)
|
||||
}
|
||||
|
||||
// Delete logs older than 30 days
|
||||
func RemoveOldLogs() {
|
||||
db := dbcore.GetDBInstance()
|
||||
@@ -1,11 +1,14 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"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"
|
||||
@@ -47,7 +50,7 @@ func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores != 0 {
|
||||
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
@@ -120,6 +123,36 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
checkInt64 := func(name string, val float64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
|
||||
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
|
||||
}
|
||||
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); 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 +213,17 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
log.Println("Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -202,14 +238,17 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
log.Println("Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -278,6 +317,14 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
|
||||
+118
-2
@@ -3,6 +3,7 @@ package clients
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
@@ -51,15 +52,129 @@ func ParseReport(data map[string]interface{}) (report common.Report, err error)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report common.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// [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
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
if err := ReportVerify(report); err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
}
|
||||
|
||||
// 保存GPU详细记录到独立表
|
||||
currentTime := time.Now()
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
for idx, gpu := range report.GPU.DetailedInfo {
|
||||
gpuRecord := models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(currentTime),
|
||||
DeviceIndex: idx,
|
||||
DeviceName: gpu.Name,
|
||||
MemTotal: gpu.MemoryTotal,
|
||||
MemUsed: gpu.MemoryUsed,
|
||||
Utilization: float32(gpu.Utilization),
|
||||
Temperature: gpu.Temperature,
|
||||
}
|
||||
if err := db.Create(&gpuRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save GPU record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均GPU使用率,用于向后兼容
|
||||
averageGPUUsage := float32(0)
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
averageGPUUsage = float32(report.GPU.AverageUsage)
|
||||
}
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: time.Now(),
|
||||
Time: models.FromTime(currentTime),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
|
||||
Gpu: averageGPUUsage, // 使用平均GPU使用率
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
@@ -75,6 +190,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
//Uptime: report.Uptime,
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
// GetClipboardByID 根据ID获取剪贴板记录
|
||||
func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
var cb models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteClipboard 删除剪贴板记录
|
||||
func DeleteClipboard(id int) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if record exists first
|
||||
var cb models.Clipboard
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return err // Record not found or other error
|
||||
}
|
||||
return db.Delete(&cb).Error
|
||||
}
|
||||
|
||||
// DeleteClipboardBatch 批量删除剪贴板记录
|
||||
func DeleteClipboardBatch(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
|
||||
}
|
||||
|
||||
// ListClipboard 列出所有剪贴板记录
|
||||
func ListClipboard() ([]models.Clipboard, error) {
|
||||
var list []models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Get() (models.Config, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.Config
|
||||
if err := db.First(&config).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
config = models.Config{
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "mmdb",
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
log.Fatal("Failed to create default config:", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Save(cst models.Config) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = time.Now()
|
||||
// Do not update CreatedAt
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("sitename",
|
||||
"description",
|
||||
"allow_cors",
|
||||
"geo_ip_enabled",
|
||||
"geo_ip_provider",
|
||||
"o_auth_client_id",
|
||||
"o_auth_client_secret",
|
||||
"o_auth_enabled",
|
||||
"custom_head",
|
||||
"updated_at").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newConfig, _ := Get()
|
||||
publishEvent(oldConfig, newConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a configuration change event
|
||||
type ConfigEvent struct {
|
||||
Old models.Config
|
||||
New models.Config
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal models.Config) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
for _, sub := range subscribers {
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
+379
-55
@@ -1,18 +1,171 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/driver/mysql"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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{}{}
|
||||
}
|
||||
|
||||
out, err := os.Create(dstZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDir)
|
||||
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 排除 backup.zip 本身
|
||||
if _, ok := normExclude[path]; ok {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 计算 zip 内相对路径
|
||||
rel, err := filepath.Rel(absSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 根目录跳过
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 替换为正斜杠
|
||||
zipName := filepath.ToSlash(rel)
|
||||
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(zipName + "/")
|
||||
return err
|
||||
}
|
||||
// 普通文件
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(zipName)
|
||||
if err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, fh); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
fh.Close()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(absDir, e.Name())
|
||||
if _, ok := normExclude[full]; ok {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
@@ -48,7 +201,7 @@ func mergeClientInfo(db *gorm.DB) {
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = info.ExpiredAt
|
||||
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)
|
||||
@@ -73,6 +226,131 @@ func MergeDatabase(db *gorm.DB) {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Merge OidcProvider table....")
|
||||
var config struct {
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for OIDC provider migration:", err)
|
||||
}
|
||||
db.AutoMigrate(&models.OidcProvider{})
|
||||
j, err := json.Marshal(&map[string]string{
|
||||
"client_id": config.OAuthClientID,
|
||||
"client_secret": config.OAuthClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal OIDC provider config:", err)
|
||||
return
|
||||
}
|
||||
db.Save(&models.OidcProvider{
|
||||
Name: "github",
|
||||
Addition: string(j),
|
||||
})
|
||||
db.AutoMigrate(&models.Config{})
|
||||
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Migrate MessageSender configuration....")
|
||||
var config struct {
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
|
||||
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
|
||||
EmailPort int `json:"email_port" gorm:"type:int"`
|
||||
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)"`
|
||||
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
|
||||
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for MessageSender migration:", err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&models.MessageSenderProvider{})
|
||||
|
||||
// 迁移Telegram配置
|
||||
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
|
||||
telegramConfig := map[string]interface{}{
|
||||
"bot_token": config.TelegramBotToken,
|
||||
"chat_id": config.TelegramChatID,
|
||||
"endpoint": config.TelegramEndpoint,
|
||||
}
|
||||
if telegramConfig["endpoint"] == "" {
|
||||
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
|
||||
}
|
||||
telegramConfigJSON, err := json.Marshal(telegramConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Telegram config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "telegram",
|
||||
Addition: string(telegramConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移Email配置
|
||||
if config.NotificationMethod == "email" && config.EmailHost != "" {
|
||||
emailConfig := map[string]interface{}{
|
||||
"host": config.EmailHost,
|
||||
"port": config.EmailPort,
|
||||
"username": config.EmailUsername,
|
||||
"password": config.EmailPassword,
|
||||
"sender": config.EmailSender,
|
||||
"receiver": config.EmailReceiver,
|
||||
"use_ssl": config.EmailUseSSL,
|
||||
}
|
||||
emailConfigJSON, err := json.Marshal(emailConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Email config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "email",
|
||||
Addition: string(emailConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧的配置字段
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_host")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_port")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_username")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_password")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_sender")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -80,49 +358,57 @@ var (
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// 初始化数据库
|
||||
// 对于 SQLite:true 如果数据库文件存在,false 如果数据库文件不存在并被创建
|
||||
// 对于 MySQL/其他数据库:总是返回 true
|
||||
// func InitDatabase() bool {
|
||||
// // 默认使用 SQLite 如果未指定类型
|
||||
// if flags.DatabaseType == "" || flags.DatabaseType == "sqlite" {
|
||||
// if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
|
||||
// log.Printf("SQLite database file %q does not exist, creating...", flags.DatabaseFile)
|
||||
// dbDir := filepath.Dir(flags.DatabaseFile)
|
||||
// if dbDir != "" {
|
||||
// if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
// log.Fatalf("Failed to create database file directory %q: %v", dbDir, err)
|
||||
// }
|
||||
// }
|
||||
// file, err := os.Create(flags.DatabaseFile)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to create SQLite database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// if err := file.Close(); err != nil {
|
||||
// log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// return false
|
||||
// } else if err != nil {
|
||||
// log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
|
||||
// }
|
||||
// return true
|
||||
// } else if flags.DatabaseType == "mysql" {
|
||||
// // 对于 MySQL,我们不需要创建文件,只需检查连接信息是否有效
|
||||
// log.Printf("Using MySQL database: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// return true
|
||||
// } else {
|
||||
// log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
once.Do(func() {
|
||||
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
//Logger: logger.Default.LogMode(logger.Silent),
|
||||
Logger: logutil.NewGormLogger(),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
@@ -134,36 +420,64 @@ func GetDBInstance() *gorm.DB {
|
||||
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)
|
||||
instance.Exec("PRAGMA wal = ON;")
|
||||
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
}
|
||||
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
|
||||
instance.Exec("PRAGMA synchronous = NORMAL;")
|
||||
instance.Exec("PRAGMA cache_size = -65536;")
|
||||
instance.Exec("PRAGMA temp_store = MEMORY;")
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
// 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)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
&models.GPURecord{},
|
||||
// &models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.PingRecord{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
)
|
||||
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.Table("gpu_records_long_term").AutoMigrate(
|
||||
&models.GPURecord{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
)
|
||||
@@ -178,6 +492,16 @@ func GetDBInstance() *gorm.DB {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
// Manually create composite indexes
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_lt_client_time ON gpu_records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_ping_record_client_time ON ping_records(client, time)")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -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,121 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalTime is a custom time type for GORM to handle time.Time correctly.
|
||||
// It ensures that all times are stored and retrieved based on the application's configured timezone.
|
||||
type LocalTime time.Time
|
||||
|
||||
// Value implements the driver.Valuer interface.
|
||||
// It converts UTCTime to a TEXT format that the database understands.
|
||||
// The time is formatted as a string in the application's local timezone, without timezone information.
|
||||
func (t LocalTime) Value() (driver.Value, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface.
|
||||
// It scans a value from the database into a UTCTime object.
|
||||
func (t *LocalTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
loc := GetAppLocation()
|
||||
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
|
||||
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
|
||||
// the date and clock values in the application's actual timezone.
|
||||
year, month, day := val.Date()
|
||||
hour, min, sec := val.Clock()
|
||||
nanosec := val.Nanosecond()
|
||||
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
|
||||
return nil
|
||||
case []byte:
|
||||
return t.parseTime(string(val), loc)
|
||||
case string:
|
||||
return t.parseTime(val, loc)
|
||||
default:
|
||||
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTime handles parsing a string into UTCTime.
|
||||
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
if timeStr == "" {
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
|
||||
*t = LocalTime(parsedTime)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
|
||||
func (t LocalTime) MarshalJSON() ([]byte, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
|
||||
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
|
||||
}
|
||||
|
||||
// ToTime converts UTCTime to Go's native time.Time type.
|
||||
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
|
||||
|
||||
// FromTime converts Go's native time.Time type to UTCTime.
|
||||
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
|
||||
|
||||
// Now returns the current time in the application's configured location.
|
||||
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
|
||||
|
||||
var (
|
||||
appLocation *time.Location
|
||||
locationOnce sync.Once
|
||||
)
|
||||
|
||||
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
|
||||
func GetAppLocation() *time.Location {
|
||||
locationOnce.Do(func() {
|
||||
tz := os.Getenv("TZ")
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
|
||||
appLocation = time.UTC
|
||||
} else {
|
||||
appLocation = loc
|
||||
}
|
||||
time.Local = appLocation
|
||||
log.Printf("Application timezone is set to '%s'.", appLocation.String())
|
||||
})
|
||||
return appLocation
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
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 LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
+30
-16
@@ -1,28 +1,42 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
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"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
|
||||
// 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"`
|
||||
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// Telegram Bot 配置
|
||||
TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
|
||||
// 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 LocalTime
|
||||
UpdatedAt LocalTime
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
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 time.Time `json:"time" gorm:"autoCreateTime;not null"`
|
||||
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package messageevent
|
||||
|
||||
const (
|
||||
Offline = "Offline"
|
||||
Online = "Online"
|
||||
Expire = "Expire"
|
||||
Renew = "Renew"
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type MessageSenderProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event string `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
+74
-34
@@ -1,35 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Client represents a registered client device
|
||||
type Client struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
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 LocalTime `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 LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
@@ -40,28 +50,29 @@ type User struct {
|
||||
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
type Session struct {
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestOnline LocalTime `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"`
|
||||
Expires LocalTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
}
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Time time.Time `json:"time" gorm:"index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time LocalTime `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"`
|
||||
@@ -79,4 +90,33 @@ type Record struct {
|
||||
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 LocalTime `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
// StringArray 存储为 JSON 的字符串切片类型
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
// 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 LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
type LoadNotification struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type OidcProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
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 LocalTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
// PingTask 表示一次延迟监测任务配置。
|
||||
type PingTask struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
|
||||
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
|
||||
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
}
|
||||
|
||||
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
|
||||
func (task PingTask) AppliesToClient(uuid string) bool {
|
||||
if uuid == "" {
|
||||
return false
|
||||
}
|
||||
for _, client := range task.Clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+9
-28
@@ -1,37 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36)"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
|
||||
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
// 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
|
||||
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:"{}"`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
notification := models.LoadNotification{
|
||||
Clients: clients,
|
||||
Name: name,
|
||||
Metric: metric,
|
||||
Threshold: threshold,
|
||||
Ratio: ratio,
|
||||
Interval: interval,
|
||||
}
|
||||
if err := db.Create(¬ification).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return notification.Id, ReloadLoadNotificationSchedule()
|
||||
}
|
||||
func DeleteLoadNotification(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func EditLoadNotification(notifications []*models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, notification := range notifications {
|
||||
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.LoadNotification
|
||||
if err := db.Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func SaveLoadNotification(record models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
}
|
||||
|
||||
func ReloadLoadNotificationSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var loadNotifications []models.LoadNotification
|
||||
if err := db.Find(&loadNotifications).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllOidcConfigs() []models.OidcProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.OidcProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.OidcProvider
|
||||
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+428
-161
@@ -2,6 +2,8 @@ package records
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -16,9 +18,59 @@ func RecordOne(rec models.Record) error {
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func RecordGPU(rec models.GPURecord) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func DeleteAll() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Exec("DELETE FROM Record").Error
|
||||
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("DELETE FROM records").Error
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取GPU记录数据
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.GPURecord
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recentRecords []models.GPURecord
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
|
||||
Order("time ASC, device_index ASC").Find(&recentRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var longTermRecords []models.GPURecord
|
||||
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
|
||||
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
|
||||
return recentRecords, nil
|
||||
}
|
||||
|
||||
// 合并结果 - 不再需要类型转换
|
||||
records = append(records, recentRecords...)
|
||||
records = append(records, longTermRecords...)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
@@ -29,98 +81,111 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
|
||||
func DeleteRecordBefore(before time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
|
||||
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
db.Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
return db.Where("time < ?", before).Delete(&models.Record{}).Error
|
||||
}
|
||||
|
||||
// 计算区间 [0.02, 0.98] 的平均值
|
||||
func QuantileMean(values []float32) float32 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
// 排序数据
|
||||
sorted := make([]float32, len(values))
|
||||
copy(sorted, values)
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[i] > sorted[j] {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.Record
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recentRecords []models.Record
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 区间边缘index
|
||||
lower := int(0.02 * float64(len(sorted)))
|
||||
upper := int(0.98 * float64(len(sorted)))
|
||||
if lower >= upper || lower >= len(sorted) {
|
||||
return 0
|
||||
|
||||
var long_term []models.Record
|
||||
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
|
||||
return recentRecords, nil
|
||||
}
|
||||
|
||||
sum := float32(0)
|
||||
count := 0
|
||||
for i := lower; i < upper && i < len(sorted); i++ {
|
||||
sum += sorted[i]
|
||||
count++
|
||||
if len(long_term) == 0 {
|
||||
// 没有查到long_term,返回全部recentRecords
|
||||
records = append(records, recentRecords...)
|
||||
return records, nil
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
|
||||
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
|
||||
grouped := make(map[string]models.Record)
|
||||
for _, rec := range recentRecords {
|
||||
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
|
||||
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
|
||||
grouped[key] = rec
|
||||
}
|
||||
}
|
||||
return sum / float32(count)
|
||||
var groupedList []models.Record
|
||||
for _, rec := range grouped {
|
||||
groupedList = append(groupedList, rec)
|
||||
}
|
||||
sort.Slice(groupedList, func(i, j int) bool {
|
||||
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
|
||||
})
|
||||
records = append(records, groupedList...)
|
||||
records = append(records, long_term...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// 压缩数据库,针对每个ClientUUID,10分钟内的数据不动,24小时内的数据精简为每15分钟一条,3天为每30分钟一条,7天为每1小时一条,30天为每12小时一条。所有精简的数据是取 [0.02,0.98] 区间内的平均值
|
||||
func GetAllRecords() ([]models.Record, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.Record
|
||||
var long_term []models.Record
|
||||
err := db.Table("records").Order("time ASC").Find(&records).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching all records: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term records: %v", err)
|
||||
return records, nil
|
||||
}
|
||||
records = append(records, long_term...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// 压缩数据库
|
||||
func CompactRecord() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
log.Printf("Compacting records...")
|
||||
var clientUUIDs []string
|
||||
if err := db.Model(&models.Record{}).Distinct("client").Pluck("client", &clientUUIDs).Error; err != nil {
|
||||
err := migrateOldRecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating old records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
oneDayAgo := now.Add(-24 * time.Hour)
|
||||
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
|
||||
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
|
||||
thirtyDaysAgo := now.Add(-30 * 24 * time.Hour)
|
||||
err = migrateGPURecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating GPU records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, clientUUID := range clientUUIDs {
|
||||
// Process each time range with specific aggregation intervals
|
||||
if err := processTimeRange(db, clientUUID, thirtyDaysAgo, sevenDaysAgo, 12*time.Hour, now); err != nil {
|
||||
log.Printf("Error compacting 30d-7d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, sevenDaysAgo, threeDaysAgo, 1*time.Hour, now); err != nil {
|
||||
log.Printf("Error compacting 7d-3d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, threeDaysAgo, oneDayAgo, 30*time.Minute, now); err != nil {
|
||||
log.Printf("Error compacting 3d-1d for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if err := processTimeRange(db, clientUUID, oneDayAgo, tenMinutesAgo, 15*time.Minute, now); err != nil {
|
||||
log.Printf("Error compacting 1d-10m for client %s: %v", clientUUID, err)
|
||||
continue
|
||||
}
|
||||
if flags.DatabaseType == "sqlite" {
|
||||
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
|
||||
}
|
||||
if flags.DatabaseType != "sqlite" {
|
||||
return nil // Only vacuum for SQLite
|
||||
}
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
log.Printf("Error vacuuming the database: %v", err)
|
||||
}
|
||||
log.Printf("Record compaction completed")
|
||||
//log.Printf("Record compaction completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, interval time.Duration, now time.Time) error {
|
||||
// Round start time to the nearest interval
|
||||
start = start.Truncate(interval)
|
||||
end = end.Truncate(interval)
|
||||
func migrateOldRecords(db *gorm.DB) error {
|
||||
// 计算 4 小时前的时间
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
|
||||
// Get all records in the time range
|
||||
// 查询 records 表中超过 4 小时的记录
|
||||
var records []models.Record
|
||||
if err := db.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).
|
||||
Order("time ASC").
|
||||
Find(&records).Error; err != nil {
|
||||
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -128,109 +193,311 @@ func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, inte
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group records by interval
|
||||
type aggregatedRecord struct {
|
||||
StartTime time.Time
|
||||
Records []models.Record
|
||||
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
|
||||
type groupData struct {
|
||||
Cpu []float32
|
||||
Gpu []float32
|
||||
Load []float32
|
||||
Temp []float32
|
||||
Ram []int64
|
||||
RamTotal []int64
|
||||
Swap []int64
|
||||
SwapTotal []int64
|
||||
Disk []int64
|
||||
DiskTotal []int64
|
||||
NetIn []int64
|
||||
NetOut []int64
|
||||
NetTotalUp []int64
|
||||
NetTotalDown []int64
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
}
|
||||
var aggregatedRecords []aggregatedRecord
|
||||
currentGroup := aggregatedRecord{StartTime: start}
|
||||
currentGroup.Records = make([]models.Record, 0)
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
for _, record := range records {
|
||||
groupTime := record.Time.Truncate(interval)
|
||||
if !groupTime.Equal(currentGroup.StartTime) {
|
||||
if len(currentGroup.Records) > 0 {
|
||||
aggregatedRecords = append(aggregatedRecords, currentGroup)
|
||||
}
|
||||
currentGroup = aggregatedRecord{StartTime: groupTime}
|
||||
currentGroup.Records = []models.Record{record}
|
||||
} else {
|
||||
currentGroup.Records = append(currentGroup.Records, record)
|
||||
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
|
||||
if _, ok := groupedRecords[key]; !ok {
|
||||
groupedRecords[key] = &groupData{}
|
||||
}
|
||||
}
|
||||
if len(currentGroup.Records) > 0 {
|
||||
aggregatedRecords = append(aggregatedRecords, currentGroup)
|
||||
data := groupedRecords[key]
|
||||
data.Cpu = append(data.Cpu, record.Cpu)
|
||||
data.Gpu = append(data.Gpu, record.Gpu)
|
||||
data.Load = append(data.Load, record.Load)
|
||||
data.Temp = append(data.Temp, record.Temp)
|
||||
data.Ram = append(data.Ram, record.Ram)
|
||||
data.RamTotal = append(data.RamTotal, record.RamTotal)
|
||||
data.Swap = append(data.Swap, record.Swap)
|
||||
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
|
||||
data.Disk = append(data.Disk, record.Disk)
|
||||
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
|
||||
data.NetIn = append(data.NetIn, record.NetIn)
|
||||
data.NetOut = append(data.NetOut, record.NetOut)
|
||||
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
|
||||
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
|
||||
data.Process = append(data.Process, record.Process)
|
||||
data.Connections = append(data.Connections, record.Connections)
|
||||
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
|
||||
//data.Uptime = append(data.Uptime, record.Uptime)
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sortedValues := make([]float64, len(values))
|
||||
copy(sortedValues, values)
|
||||
sort.Float64s(sortedValues)
|
||||
index := float64(len(sortedValues)-1) * percentile
|
||||
lowerIndex := int(index)
|
||||
if lowerIndex >= len(sortedValues)-1 {
|
||||
return sortedValues[len(sortedValues)-1]
|
||||
}
|
||||
frac := index - float64(lowerIndex)
|
||||
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
|
||||
}
|
||||
|
||||
// Delete original records in the time range
|
||||
if err := tx.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).Delete(&models.Record{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
getIntPercentile := func(values []int64, percentile float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int64(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
getInt32Percentile := func(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, data := range groupedRecords {
|
||||
// 解析 Client 和时间
|
||||
parts := strings.Split(key, "_")
|
||||
clientUUID := parts[0]
|
||||
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cpuFloats := make([]float64, len(data.Cpu))
|
||||
for i, v := range data.Cpu {
|
||||
cpuFloats[i] = float64(v)
|
||||
}
|
||||
gpuFloats := make([]float64, len(data.Gpu))
|
||||
for i, v := range data.Gpu {
|
||||
gpuFloats[i] = float64(v)
|
||||
}
|
||||
loadFloats := make([]float64, len(data.Load))
|
||||
for i, v := range data.Load {
|
||||
loadFloats[i] = float64(v)
|
||||
}
|
||||
tempFloats := make([]float64, len(data.Temp))
|
||||
for i, v := range data.Temp {
|
||||
tempFloats[i] = float64(v)
|
||||
}
|
||||
// 取高位
|
||||
high_percentile := 0.7
|
||||
// 检查 records_long_term 表中是否已存在相同的记录
|
||||
var existingCount int64
|
||||
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newRec := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(timeSlot),
|
||||
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
|
||||
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
|
||||
Load: float32(getPercentile(loadFloats, high_percentile)),
|
||||
Temp: float32(getPercentile(tempFloats, high_percentile)),
|
||||
Ram: getIntPercentile(data.Ram, high_percentile),
|
||||
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
|
||||
Swap: getIntPercentile(data.Swap, high_percentile),
|
||||
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
|
||||
Disk: getIntPercentile(data.Disk, high_percentile),
|
||||
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
|
||||
NetIn: getIntPercentile(data.NetIn, 0.2),
|
||||
NetOut: getIntPercentile(data.NetOut, 0.2),
|
||||
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
|
||||
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
|
||||
Process: getInt32Percentile(data.Process, high_percentile),
|
||||
Connections: getInt32Percentile(data.Connections, high_percentile),
|
||||
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
|
||||
//Uptime: getIntPercentile(data.Uptime, high_percentile),
|
||||
}
|
||||
|
||||
// 如果记录已存在则更新,否则创建新记录
|
||||
if existingCount > 0 {
|
||||
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 records 表中的旧数据
|
||||
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// migrateGPURecords 压缩GPU记录数据
|
||||
func migrateGPURecords(db *gorm.DB) error {
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
|
||||
// 查询超过4小时的GPU记录
|
||||
var gpuRecords []models.GPURecord
|
||||
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert aggregated records
|
||||
for _, agg := range aggregatedRecords {
|
||||
if len(agg.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate averages and sums
|
||||
var sumCPU, sumGPU, sumLOAD, sumTEMP float32
|
||||
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal int64
|
||||
var sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
|
||||
var sumPROCESS, sumConnections, sumConnectionsUDP int
|
||||
count := len(agg.Records)
|
||||
|
||||
for _, r := range agg.Records {
|
||||
sumCPU += r.Cpu
|
||||
sumGPU += r.Gpu
|
||||
sumLOAD += r.Load
|
||||
sumTEMP += r.Temp
|
||||
sumRAM += r.Ram
|
||||
sumRAMTotal += r.RamTotal
|
||||
sumSWAP += r.Swap
|
||||
sumSWAPTotal += r.SwapTotal
|
||||
sumDISK += r.Disk
|
||||
sumDISKTotal += r.DiskTotal
|
||||
sumNETIn += r.NetIn
|
||||
sumNETOut += r.NetOut
|
||||
sumNETTotalUp += r.NetTotalUp
|
||||
sumNETTotalDown += r.NetTotalDown
|
||||
sumPROCESS += r.Process
|
||||
sumConnections += r.Connections
|
||||
sumConnectionsUDP += r.ConnectionsUdp
|
||||
}
|
||||
|
||||
// Create new aggregated record
|
||||
newRecord := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: agg.StartTime,
|
||||
Cpu: sumCPU / float32(count),
|
||||
Gpu: sumGPU / float32(count),
|
||||
Ram: sumRAM / int64(count),
|
||||
RamTotal: sumRAMTotal / int64(count),
|
||||
Swap: sumSWAP / int64(count),
|
||||
SwapTotal: sumSWAPTotal / int64(count),
|
||||
Load: sumLOAD / float32(count),
|
||||
Temp: sumTEMP / float32(count),
|
||||
Disk: sumDISK / int64(count),
|
||||
DiskTotal: sumDISKTotal / int64(count),
|
||||
NetIn: sumNETIn / int64(count),
|
||||
NetOut: sumNETOut / int64(count),
|
||||
NetTotalUp: sumNETTotalUp / int64(count),
|
||||
NetTotalDown: sumNETTotalDown / int64(count),
|
||||
Process: sumPROCESS / count,
|
||||
Connections: sumConnections / count,
|
||||
ConnectionsUdp: sumConnectionsUDP / count,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newRecord).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if len(gpuRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
// 按Client + DeviceIndex + 15分钟时间窗口分组
|
||||
type gpuGroupKey struct {
|
||||
Client string
|
||||
DeviceIndex int
|
||||
TimeSlot time.Time
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
func Save(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
type gpuGroupData struct {
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float32
|
||||
Temperature []int
|
||||
}
|
||||
|
||||
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
key := gpuGroupKey{
|
||||
Client: record.Client,
|
||||
DeviceIndex: record.DeviceIndex,
|
||||
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
|
||||
DeviceName: record.DeviceName,
|
||||
}
|
||||
|
||||
if _, ok := groupedGPUs[key]; !ok {
|
||||
groupedGPUs[key] = &gpuGroupData{}
|
||||
}
|
||||
|
||||
data := groupedGPUs[key]
|
||||
data.MemTotal = append(data.MemTotal, record.MemTotal)
|
||||
data.MemUsed = append(data.MemUsed, record.MemUsed)
|
||||
data.Utilization = append(data.Utilization, record.Utilization)
|
||||
data.Temperature = append(data.Temperature, record.Temperature)
|
||||
}
|
||||
|
||||
// 百分位数计算函数 (复用传统Record压缩逻辑)
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sortedValues := make([]float64, len(values))
|
||||
copy(sortedValues, values)
|
||||
sort.Float64s(sortedValues)
|
||||
index := float64(len(sortedValues)-1) * percentile
|
||||
lowerIndex := int(index)
|
||||
if lowerIndex >= len(sortedValues)-1 {
|
||||
return sortedValues[len(sortedValues)-1]
|
||||
}
|
||||
frac := index - float64(lowerIndex)
|
||||
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
|
||||
}
|
||||
|
||||
getIntPercentile := func(values []int64, percentile float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int64(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 温度数据转换辅助函数
|
||||
convertIntToInt64 := func(values []int) []int64 {
|
||||
result := make([]int64, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = int64(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getFloat32Percentile := func(values []float32, percentile float64) float32 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return float32(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 保持与传统Record压缩的一致性
|
||||
high_percentile := 0.7
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, data := range groupedGPUs {
|
||||
// 检查是否已存在记录
|
||||
var existingCount int64
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compressedGPU := models.GPURecord{
|
||||
Client: key.Client,
|
||||
DeviceIndex: key.DeviceIndex,
|
||||
Time: models.FromTime(key.TimeSlot),
|
||||
DeviceName: key.DeviceName,
|
||||
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
|
||||
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
|
||||
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
|
||||
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
|
||||
}
|
||||
|
||||
if existingCount > 0 {
|
||||
// 更新已存在记录
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 创建新记录
|
||||
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除已压缩的原始GPU数据
|
||||
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user