mirror of
https://github.com/komari-monitor/komari.git
synced 2026-10-29 18:11:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d67686db | ||
|
|
96462528c8 | ||
|
|
d92f60f757 | ||
|
|
cba8153d4a | ||
|
|
08067bc8f8 | ||
|
|
a714637fee | ||
|
|
956b9334d0 | ||
|
|
996dd90386 | ||
|
|
28abafb5b2 | ||
|
|
5488189e5f | ||
|
|
d9713aa696 | ||
|
|
eab03a1da5 | ||
|
|
1f139ba3bf | ||
|
|
4040a5d9c7 | ||
|
|
01c4816d1c | ||
|
|
c27c21d5c9 | ||
|
|
f967ba07f9 | ||
|
|
91d5f92c55 | ||
|
|
965afb3f77 | ||
|
|
68bf4ed148 | ||
|
|
8985adcf67 | ||
|
|
91cbeb3f82 | ||
|
|
91ae9eda1a | ||
|
|
54851bd1cf | ||
|
|
3c5838283e | ||
|
|
df6ffba18c | ||
|
|
4077201f09 | ||
|
|
05a91adc53 | ||
|
|
1c75cb8be0 | ||
|
|
d69effee7a | ||
|
|
ebe5278183 | ||
|
|
16d6f2670d | ||
|
|
abc53b3cc0 | ||
|
|
a1ae82169c | ||
|
|
8c849aa423 | ||
|
|
0d699951b7 | ||
|
|
1ffe413eb7 | ||
|
|
c3c3ed8ecf | ||
|
|
0103b99651 | ||
|
|
b8c100823f | ||
|
|
c7819cd96e | ||
|
|
17aa37c64f | ||
|
|
045f24ef85 | ||
|
|
6ab9c6724e | ||
|
|
6efc72ce31 | ||
|
|
1f6bd1391a | ||
|
|
5b93181d94 | ||
|
|
f07bd749c4 | ||
|
|
887381ad4f | ||
|
|
11617b46b3 | ||
|
|
5a8488926c | ||
|
|
db2ed8e424 | ||
|
|
312834f921 | ||
|
|
9d149ab660 | ||
|
|
9d7e24ab78 | ||
|
|
99c684e75e | ||
|
|
a402cc6189 | ||
|
|
0bac110d8a | ||
|
|
7a955467fd | ||
|
|
eb7ecc6fe4 | ||
|
|
f59e0e813c | ||
|
|
b4d3977871 | ||
|
|
b55436842f | ||
|
|
cdfd5bd13f | ||
|
|
5a66f28e81 | ||
|
|
be3daa42fc | ||
|
|
42b290f169 | ||
|
|
99fda6b023 | ||
|
|
7f128509e9 | ||
|
|
fee8c860d7 | ||
|
|
3c278230f4 | ||
|
|
d853a83ec6 | ||
|
|
a78ef878eb | ||
|
|
312e320c8f | ||
|
|
73c3c436a4 | ||
|
|
f42cfe7ddc | ||
|
|
0f6f2f4294 | ||
|
|
fc3febc3be | ||
|
|
5b90804a13 | ||
|
|
43547b947f | ||
|
|
e2a8284c03 | ||
|
|
c1178ebfe1 | ||
|
|
7baf2edf26 | ||
|
|
66b7691e01 | ||
|
|
7f368817e0 | ||
|
|
5fa59ab7de | ||
|
|
0c80f0f23a | ||
|
|
1eb3549ed9 | ||
|
|
5c73eba475 | ||
|
|
76794305c8 | ||
|
|
724dd3b666 | ||
|
|
c828653d20 | ||
|
|
ff358edd8b | ||
|
|
3b48339209 | ||
|
|
42ef993cd6 | ||
|
|
39c5f7bb69 | ||
|
|
9831df0af8 | ||
|
|
29eecf55f7 | ||
|
|
0660ee74d4 | ||
|
|
5ade710f32 | ||
|
|
855ff82f97 | ||
|
|
8910c2e9c7 | ||
|
|
b2fe9ab068 | ||
|
|
8a529b4eb0 | ||
|
|
46b4e37178 | ||
|
|
5ad791fe50 | ||
|
|
8911c08a5b | ||
|
|
7d95240bc1 | ||
|
|
7e0d542694 | ||
|
|
84130dfff5 | ||
|
|
e0094447a6 | ||
|
|
ab31fc69fe | ||
|
|
73071ff1e8 | ||
|
|
4ad190a7f4 | ||
|
|
6ddb143365 | ||
|
|
eb4ca83a13 | ||
|
|
43c80d238b | ||
|
|
416ea871be | ||
|
|
5c2bd4ec74 | ||
|
|
be6af33d85 | ||
|
|
d02d9347dc | ||
|
|
64998b527c | ||
|
|
9e5b6df196 | ||
|
|
332053d8ec | ||
|
|
b6fcdd396d | ||
|
|
92ec774249 | ||
|
|
404d97d846 | ||
|
|
0132d1cbd8 | ||
|
|
ff6daa6ce3 | ||
|
|
6a5f3ef04d | ||
|
|
da047cf612 | ||
|
|
f0877d6d32 | ||
|
|
f2c46dbfb1 | ||
|
|
5f50d1ba00 | ||
|
|
7e19909dd6 | ||
|
|
9db4daac10 | ||
|
|
0a4cd6bd8c | ||
|
|
20c865ed20 | ||
|
|
e740bf2797 | ||
|
|
074958fad7 | ||
|
|
11d3ba8236 | ||
|
|
ce41745edb | ||
|
|
036121c148 | ||
|
|
dd0539392b | ||
|
|
50ba8310ca | ||
|
|
50fe286528 | ||
|
|
144efdc8ad | ||
|
|
e446bee900 | ||
|
|
a980befd60 | ||
|
|
2e0c598dae | ||
|
|
771a192a3d | ||
|
|
95cc65e28d | ||
|
|
b6e36ee0c4 | ||
|
|
25f3e1d2bd | ||
|
|
091eafa743 | ||
|
|
cbe665591f | ||
|
|
0abd9ff9bc | ||
|
|
618ced3b8f | ||
|
|
f7fd459723 | ||
|
|
bb22a818bf | ||
|
|
b66fe47487 | ||
|
|
98122fa4d1 | ||
|
|
c3f65b1a32 | ||
|
|
7da616ce4c | ||
|
|
9d914e70b9 | ||
|
|
6f0624e9b4 | ||
|
|
3cc67e11cd | ||
|
|
742a5e953e | ||
|
|
3b82cee4b1 | ||
|
|
2b010954a3 | ||
|
|
6f23565517 | ||
|
|
7c2896c3fe | ||
|
|
721d61bc4e | ||
|
|
014b236745 | ||
|
|
80c8d7e5ee | ||
|
|
8423881cc4 | ||
|
|
a1d644977c | ||
|
|
301fb536f2 | ||
|
|
49a8d772c8 | ||
|
|
a9adae29d2 | ||
|
|
d6e23baa19 | ||
|
|
658e7603f8 | ||
|
|
fb0b9b9910 | ||
|
|
e149e8b8b7 | ||
|
|
d2cc1503de | ||
|
|
f86803feff | ||
|
|
a2e3c7218d | ||
|
|
567d2b66fb | ||
|
|
473dbb9057 | ||
|
|
89bd47d5df | ||
|
|
10c4a9dafc | ||
|
|
f4d73bf8c9 | ||
|
|
441e2e9194 | ||
|
|
c15881c463 | ||
|
|
f5acbd2efe | ||
|
|
e07378c92b | ||
|
|
cd38d32a7d | ||
|
|
53f326390e | ||
|
|
651d24128c | ||
|
|
2660026917 | ||
|
|
ac7ace27cb | ||
|
|
1de008269b | ||
|
|
4bdbff0377 | ||
|
|
3415c06256 | ||
|
|
9f3da4c17a | ||
|
|
3d0c83557e | ||
|
|
e319eb7f20 | ||
|
|
7f007b7eb9 | ||
|
|
9e665005e6 | ||
|
|
b5b32b5f64 | ||
|
|
89a699b6cb | ||
|
|
79cbf2d2d1 | ||
|
|
ab00c9529e | ||
|
|
21500a6727 | ||
|
|
066b944789 | ||
|
|
d7dd816a26 | ||
|
|
c5b464c000 | ||
|
|
645c733393 | ||
|
|
85088e64ca | ||
|
|
896b3e45fc | ||
|
|
490f951b59 | ||
|
|
69c96255d7 | ||
|
|
487542e55d | ||
|
|
ca8fe76c06 | ||
|
|
e6e3619cd2 |
No files matched your search
@@ -0,0 +1,62 @@
|
||||
name: Build frontend theme
|
||||
description: Clone komari-web, build the default theme, and optionally upload it as an artifact.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version used to build komari-web.
|
||||
default: "23"
|
||||
repository:
|
||||
description: komari-web repository URL.
|
||||
default: https://github.com/komari-monitor/komari-web
|
||||
ref:
|
||||
description: Optional komari-web ref to build. Uses the repository default branch when empty.
|
||||
default: ""
|
||||
artifact-name:
|
||||
description: Optional artifact name for the packed frontend theme.
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Clone and build frontend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
rm -rf komari-web
|
||||
if [ -n "${{ inputs.ref }}" ]; then
|
||||
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
|
||||
else
|
||||
git clone --depth=1 "${{ inputs.repository }}" komari-web
|
||||
fi
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Pack frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
tar -czf "$RUNNER_TEMP/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme .
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: ${{ inputs.artifact-name != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/${{ inputs.artifact-name }}.tar.gz
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Restore frontend theme
|
||||
description: Download and unpack a previously built frontend theme artifact.
|
||||
|
||||
inputs:
|
||||
artifact-name:
|
||||
description: Frontend theme artifact name.
|
||||
default: komari-frontend
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/frontend-artifact
|
||||
|
||||
- name: Extract frontend artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p web/public/defaultTheme
|
||||
rm -rf web/public/defaultTheme/*
|
||||
tar -xzf "$RUNNER_TEMP/frontend-artifact/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Setup Zig
|
||||
description: Restore or install the Zig toolchain used for CGO cross-compilation.
|
||||
|
||||
inputs:
|
||||
zig-version:
|
||||
description: Zig version to install.
|
||||
default: 0.14.1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore Zig cache
|
||||
id: zig-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}
|
||||
key: zig-${{ runner.os }}-x86_64-${{ inputs.zig-version }}
|
||||
|
||||
- name: Download Zig
|
||||
if: ${{ steps.zig-cache.outputs.cache-hit != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p "$HOME/.cache/komari-zig"
|
||||
curl -fsSL "https://ziglang.org/download/${{ inputs.zig-version }}/zig-x86_64-linux-${{ inputs.zig-version }}.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
|
||||
tar -C "$HOME/.cache/komari-zig" -Jxf "$RUNNER_TEMP/zig.tar.xz"
|
||||
|
||||
- name: Add Zig to PATH
|
||||
shell: bash
|
||||
run: |
|
||||
echo "$HOME/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}" >> "$GITHUB_PATH"
|
||||
+21
-31
@@ -1,16 +1,26 @@
|
||||
name: Build Binaries on Main Push and PR
|
||||
name: Build Binaries on PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -49,6 +59,10 @@ jobs:
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
@@ -58,27 +72,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/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: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -86,12 +81,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
|
||||
@@ -16,25 +16,11 @@ jobs:
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/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
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -11,37 +11,28 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-multi-arch:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
node-version: "23"
|
||||
artifact-name: komari-frontend
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/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
|
||||
build-docker-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -49,10 +40,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
run: |
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
@@ -70,6 +58,29 @@ jobs:
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to regenerate notes for. Leave empty to use the latest release."
|
||||
required: false
|
||||
type: string
|
||||
base_ref:
|
||||
description: "Base ref or SHA for the release comparison. Snapshot builds pass the previous main SHA."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'Snapshot-') }}
|
||||
|
||||
steps:
|
||||
- name: Generate and apply release notes
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const webRepo = { owner: "komari-monitor", repo: "komari-web" };
|
||||
|
||||
const AI_CONTEXT_CHAR_LIMIT = 120000;
|
||||
const AI_MAX_COMMITS = 160;
|
||||
const AI_MAX_CHANGED_FILES = 120;
|
||||
const AI_MAX_PULL_REQUESTS = 80;
|
||||
const AI_MAX_PATCH_CHARS_PER_FILE = 2200;
|
||||
const OPENAI_TIMEOUT_MS = 120000;
|
||||
|
||||
const clean = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
const isSnapshotTag = (tag) => /^Snapshot-/i.test(clean(tag));
|
||||
const releaseTimestamp = (release) => Date.parse(release.published_at || release.created_at || "");
|
||||
const isStableRelease = (release) => !release.draft && !release.prerelease && !isSnapshotTag(release.tag_name);
|
||||
|
||||
function limitText(value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
|
||||
}
|
||||
|
||||
function appendWithinLimit(parts, value, maxLength) {
|
||||
const text = clean(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentLength = parts.reduce((total, part) => total + part.length + 2, 0);
|
||||
if (currentLength + text.length + 2 <= maxLength) {
|
||||
parts.push(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
const remaining = maxLength - currentLength - 64;
|
||||
if (remaining > 500) {
|
||||
parts.push(`${text.slice(0, remaining)}\n...[context truncated]`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveRelease() {
|
||||
const requestedTag = clean(context.payload.release?.tag_name)
|
||||
|| clean(context.payload.client_payload?.tag)
|
||||
|| clean(context.payload.inputs?.tag);
|
||||
|
||||
if (requestedTag) {
|
||||
const release = context.payload.release?.tag_name === requestedTag
|
||||
? context.payload.release
|
||||
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag: requestedTag })).data;
|
||||
return { tag: requestedTag, release };
|
||||
}
|
||||
|
||||
const release = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
|
||||
return { tag: release.tag_name, release };
|
||||
}
|
||||
|
||||
async function getPreviousReleaseTag(tag, release) {
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const currentTime = releaseTimestamp(release);
|
||||
const currentIsSnapshot = isSnapshotTag(tag);
|
||||
const currentIsStable = isStableRelease(release);
|
||||
const candidates = releases
|
||||
.filter((item) => !item.draft && item.tag_name !== tag)
|
||||
.filter((item) => {
|
||||
if (!Number.isFinite(currentTime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemTime = releaseTimestamp(item);
|
||||
return Number.isFinite(itemTime) && itemTime < currentTime;
|
||||
})
|
||||
.filter((item) => {
|
||||
if (currentIsSnapshot || currentIsStable) {
|
||||
return isStableRelease(item);
|
||||
}
|
||||
|
||||
return item.prerelease && !isSnapshotTag(item.tag_name);
|
||||
})
|
||||
.sort((left, right) => releaseTimestamp(right) - releaseTimestamp(left));
|
||||
|
||||
return candidates[0]?.tag_name || "";
|
||||
}
|
||||
|
||||
async function resolveComparisonBase(tag, release) {
|
||||
const requestedBaseRef = clean(context.payload.client_payload?.base_ref)
|
||||
|| clean(context.payload.inputs?.base_ref);
|
||||
|
||||
if (requestedBaseRef) {
|
||||
return {
|
||||
ref: requestedBaseRef,
|
||||
previousTag: "",
|
||||
source: "requested base ref",
|
||||
};
|
||||
}
|
||||
|
||||
const previousTag = await getPreviousReleaseTag(tag, release);
|
||||
if (previousTag) {
|
||||
return {
|
||||
ref: previousTag,
|
||||
previousTag,
|
||||
source: isSnapshotTag(tag) ? "previous stable release" : "previous release in the same channel",
|
||||
};
|
||||
}
|
||||
|
||||
if (isSnapshotTag(tag)) {
|
||||
try {
|
||||
const commit = (await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: release.target_commitish || tag,
|
||||
})).data;
|
||||
const parentSha = commit.parents?.[0]?.sha || "";
|
||||
if (parentSha) {
|
||||
return {
|
||||
ref: parentSha,
|
||||
previousTag: "",
|
||||
source: "snapshot target parent commit",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to resolve parent commit for ${tag}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ref: "",
|
||||
previousTag: "",
|
||||
source: "none",
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeneratedNotes(tag, release, previousTag) {
|
||||
const params = {
|
||||
owner,
|
||||
repo,
|
||||
tag_name: tag,
|
||||
target_commitish: release.target_commitish || undefined,
|
||||
};
|
||||
|
||||
if (previousTag) {
|
||||
params.previous_tag_name = previousTag;
|
||||
}
|
||||
|
||||
const generated = await github.request(
|
||||
"POST /repos/{owner}/{repo}/releases/generate-notes",
|
||||
params,
|
||||
);
|
||||
|
||||
return clean(generated.data.body);
|
||||
}
|
||||
|
||||
async function getComparison(baseRef, tag) {
|
||||
if (!baseRef) {
|
||||
return { commits: [], files: [], htmlUrl: "" };
|
||||
}
|
||||
|
||||
const commits = [];
|
||||
let files = [];
|
||||
let htmlUrl = "";
|
||||
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const response = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseRef}...${tag}`,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
|
||||
if (page === 1) {
|
||||
files = response.data.files || [];
|
||||
htmlUrl = response.data.html_url || "";
|
||||
}
|
||||
|
||||
commits.push(...response.data.commits);
|
||||
|
||||
if (!response.headers.link?.includes('rel="next"')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { commits, files, htmlUrl };
|
||||
}
|
||||
|
||||
function renderCommitLine(commit) {
|
||||
const subject = clean(commit.commit.message.split("\n")[0]) || "(no subject)";
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login
|
||||
? ` @${commit.author.login}`
|
||||
: (commit.commit.author?.name ? ` ${commit.commit.author.name}` : "");
|
||||
|
||||
return `- ${subject} ([${shortSha}](${commit.html_url}))${author}`;
|
||||
}
|
||||
|
||||
function buildWhatsChangedSection(baseRef, tag, comparison) {
|
||||
if (!baseRef) {
|
||||
return "## What's Changed\n\nNo comparison base was found for this release.";
|
||||
}
|
||||
|
||||
if (comparison.commits.length === 0) {
|
||||
return `## What's Changed\n\nNo commits found between \`${baseRef}\` and \`${tag}\`.`;
|
||||
}
|
||||
|
||||
const lines = ["## What's Changed", "", ...comparison.commits.map(renderCommitLine)];
|
||||
if (comparison.htmlUrl) {
|
||||
lines.push("", `**Full Changelog**: ${comparison.htmlUrl}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendCommitsToGeneratedNotes(generatedNotes, baseRef, tag, comparison) {
|
||||
const notes = clean(generatedNotes);
|
||||
if (!notes || comparison.commits.length === 0) {
|
||||
return notes || buildWhatsChangedSection(baseRef, tag, comparison);
|
||||
}
|
||||
|
||||
const lines = notes.split("\n");
|
||||
const fullChangelogIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/i.test(line.trim()));
|
||||
const nextTopLevelSectionIndex = lines.findIndex((line, index) => (
|
||||
index > 0
|
||||
&& /^##\s+/.test(line)
|
||||
&& !/^##\s+What's Changed\b/i.test(line)
|
||||
));
|
||||
const insertionIndex = nextTopLevelSectionIndex >= 0
|
||||
? nextTopLevelSectionIndex
|
||||
: (fullChangelogIndex >= 0 ? fullChangelogIndex : lines.length);
|
||||
const commitSection = [
|
||||
"### Commits",
|
||||
"",
|
||||
...comparison.commits.map(renderCommitLine),
|
||||
"",
|
||||
];
|
||||
|
||||
lines.splice(insertionIndex, 0, ...commitSection);
|
||||
return clean(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function getPullRequestContext(commits) {
|
||||
const pullRequests = new Map();
|
||||
|
||||
for (const commit of commits.slice(0, AI_MAX_COMMITS)) {
|
||||
try {
|
||||
const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: commit.sha,
|
||||
});
|
||||
|
||||
for (const pullRequest of response.data || []) {
|
||||
pullRequests.set(pullRequest.html_url, pullRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to load pull requests for ${commit.sha.slice(0, 7)}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (pullRequests.size >= AI_MAX_PULL_REQUESTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...pullRequests.values()].slice(0, AI_MAX_PULL_REQUESTS);
|
||||
}
|
||||
|
||||
function buildChangedFilesContext(files) {
|
||||
if (!files.length) {
|
||||
return "No changed file metadata was returned by GitHub.";
|
||||
}
|
||||
|
||||
return files.slice(0, AI_MAX_CHANGED_FILES).map((file) => {
|
||||
const lines = [
|
||||
`File: ${file.filename}`,
|
||||
`Status: ${file.status}; additions: ${file.additions}; deletions: ${file.deletions}; changes: ${file.changes}`,
|
||||
];
|
||||
|
||||
if (file.patch) {
|
||||
lines.push("Patch excerpt:");
|
||||
lines.push(limitText(file.patch, AI_MAX_PATCH_CHARS_PER_FILE));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildCommitContext(commits) {
|
||||
if (!commits.length) {
|
||||
return "No commits were returned by GitHub.";
|
||||
}
|
||||
|
||||
return commits.slice(0, AI_MAX_COMMITS).map((commit) => {
|
||||
const shortSha = commit.sha.slice(0, 7);
|
||||
const author = commit.author?.login || commit.commit.author?.name || "unknown";
|
||||
const message = limitText(commit.commit.message, 1800);
|
||||
return [
|
||||
`Commit: ${shortSha}`,
|
||||
`Author: ${author}`,
|
||||
`URL: ${commit.html_url}`,
|
||||
"Message:",
|
||||
message,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildPullRequestContext(pullRequests) {
|
||||
if (!pullRequests.length) {
|
||||
return "No associated pull requests were found for the compared commits.";
|
||||
}
|
||||
|
||||
return pullRequests.map((pullRequest) => {
|
||||
const labels = (pullRequest.labels || []).map((label) => label.name).filter(Boolean).join(", ") || "none";
|
||||
return [
|
||||
`PR: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`URL: ${pullRequest.html_url}`,
|
||||
`Author: ${pullRequest.user?.login || "unknown"}`,
|
||||
`Labels: ${labels}`,
|
||||
"Body:",
|
||||
limitText(pullRequest.body, 1800) || "(empty)",
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function buildAiContext({ tag, release, comparisonBase, whatsChangedSection, comparison, pullRequests, webReleaseSection }) {
|
||||
const sections = [];
|
||||
|
||||
appendWithinLimit(sections, [
|
||||
`Repository: ${owner}/${repo}`,
|
||||
`Release tag: ${tag}`,
|
||||
`Release title: ${release.name || tag}`,
|
||||
`Prerelease: ${release.prerelease ? "yes" : "no"}`,
|
||||
`Snapshot tag: ${isSnapshotTag(tag) ? "yes" : "no"}`,
|
||||
`Target commitish: ${release.target_commitish || "(unknown)"}`,
|
||||
`Comparison base: ${comparisonBase.ref || "(none)"}`,
|
||||
`Comparison base source: ${comparisonBase.source}`,
|
||||
`Compare URL: ${comparison.htmlUrl || "(unavailable)"}`,
|
||||
].join("\n"), AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
appendWithinLimit(sections, `What's Changed section:\n${whatsChangedSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Changed files and patch excerpts:\n${buildChangedFilesContext(comparison.files)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Associated pull requests:\n${buildPullRequestContext(pullRequests)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
appendWithinLimit(sections, `Commit details:\n${buildCommitContext(comparison.commits)}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
|
||||
if (webReleaseSection) {
|
||||
appendWithinLimit(sections, `Komari Web release notes for the same tag:\n${webReleaseSection}`, AI_CONTEXT_CHAR_LIMIT);
|
||||
}
|
||||
|
||||
return sections.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildChatCompletionsUrl(baseUrl) {
|
||||
const trimmed = clean(baseUrl).replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/chat/completions")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
return `${trimmed}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function normalizeReleaseSummary(markdown) {
|
||||
const summary = clean(markdown);
|
||||
if (!summary) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutAiHeading = clean(summary
|
||||
.replace(/^#{1,6}\s+AI Summary\b\s*/im, "")
|
||||
.replace(/^\*\*AI Summary\*\*\s*/im, ""));
|
||||
const whatsChangedIndex = withoutAiHeading.search(/^##\s+What's Changed\b/im);
|
||||
if (whatsChangedIndex >= 0) {
|
||||
return clean(withoutAiHeading.slice(0, whatsChangedIndex));
|
||||
}
|
||||
|
||||
return withoutAiHeading;
|
||||
}
|
||||
|
||||
async function callOpenAiChatCompletion(url, apiKey, model, messages) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model, messages, temperature: 0 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseJson = {};
|
||||
try {
|
||||
responseJson = responseText ? JSON.parse(responseText) : {};
|
||||
} catch {
|
||||
responseJson = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = clean(responseJson.error?.message) || limitText(responseText, 500);
|
||||
throw new Error(`OpenAI request failed with ${response.status}: ${message}`);
|
||||
}
|
||||
|
||||
return clean(responseJson.choices?.[0]?.message?.content);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReleaseSummarySection({ tag, release, comparisonBase, whatsChangedSection, comparison, webReleaseSection }) {
|
||||
const apiKey = clean(process.env.OPENAI_API_KEY);
|
||||
const baseUrl = clean(process.env.OPENAI_BASE_URL);
|
||||
const model = clean(process.env.OPENAI_MODEL);
|
||||
|
||||
if (!apiKey || !baseUrl || !model) {
|
||||
core.warning("OPENAI_API_KEY, OPENAI_BASE_URL, or OPENAI_MODEL is missing. Skipping generated release summary.");
|
||||
return "";
|
||||
}
|
||||
|
||||
const pullRequests = await getPullRequestContext(comparison.commits);
|
||||
const aiContext = buildAiContext({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
pullRequests,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"You write accurate release notes for the Komari server project.",
|
||||
"Use only the supplied repository context.",
|
||||
"Include both Komari server changes and Komari Web frontend changes when frontend release notes are supplied.",
|
||||
"Prioritize user-visible behavior, compatibility or migration notes, operations/deployment changes, security fixes, and important bug fixes.",
|
||||
"Do not invent features or implementation details that are not supported by the context.",
|
||||
"Write concise Markdown in Simplified Chinese.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
`Create the release summary section for release ${tag}.`,
|
||||
comparisonBase.ref
|
||||
? `Summarize only changes in the comparison ${comparisonBase.ref}...${tag}.`
|
||||
: "No comparison base was found, so summarize only the supplied release context.",
|
||||
"Return only Markdown for the summary section that appears before '## What's Changed'.",
|
||||
"Do not use the heading 'AI Summary'. Do not add a 'What's Changed' section.",
|
||||
"Use these top-level headings, in this order: '## 主要更新内容', '## Bug修复', and '## 其他'.",
|
||||
"Omit '## Bug修复' when there are no concrete bug fixes. Keep bullets short and specific.",
|
||||
"",
|
||||
aiContext,
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const url = buildChatCompletionsUrl(baseUrl);
|
||||
const summary = await callOpenAiChatCompletion(url, apiKey, model, messages);
|
||||
return normalizeReleaseSummary(summary);
|
||||
} catch (error) {
|
||||
core.warning(`Generated release summary failed: ${error.message}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWebReleaseSection(tag) {
|
||||
const demoteMarkdownHeadings = (markdown) => markdown.replace(/^(#{1,5})(\s+)/gm, "#$1$2");
|
||||
|
||||
try {
|
||||
const webRelease = (await github.rest.repos.getReleaseByTag({
|
||||
owner: webRepo.owner,
|
||||
repo: webRepo.repo,
|
||||
tag,
|
||||
})).data;
|
||||
|
||||
const webBody = clean(webRelease.body);
|
||||
if (!webBody) {
|
||||
return `## Komari Web Release Notes\n\nKomari Web release [${tag}](${webRelease.html_url}) exists, but its release notes are empty.`;
|
||||
}
|
||||
|
||||
return [
|
||||
"## Komari Web Release Notes",
|
||||
"",
|
||||
`Source: [komari-web ${tag}](${webRelease.html_url})`,
|
||||
"",
|
||||
demoteMarkdownHeadings(webBody),
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Komari Web release ${tag} does not exist yet. Skipping web release notes for now.`);
|
||||
return "";
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const { tag, release } = await resolveRelease();
|
||||
if (!tag) {
|
||||
core.setFailed("No release tag was provided or found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comparisonBase = await resolveComparisonBase(tag, release);
|
||||
core.info(comparisonBase.ref
|
||||
? `Comparison base for ${tag}: ${comparisonBase.ref} (${comparisonBase.source}).`
|
||||
: `No comparison base found for ${tag}.`);
|
||||
|
||||
const comparison = await getComparison(comparisonBase.ref, tag);
|
||||
const whatsChangedSection = comparisonBase.previousTag
|
||||
? appendCommitsToGeneratedNotes(
|
||||
await getGeneratedNotes(tag, release, comparisonBase.previousTag),
|
||||
comparisonBase.ref,
|
||||
tag,
|
||||
comparison,
|
||||
)
|
||||
: (!comparisonBase.ref && !isSnapshotTag(tag)
|
||||
? await getGeneratedNotes(tag, release, "")
|
||||
: buildWhatsChangedSection(comparisonBase.ref, tag, comparison));
|
||||
|
||||
if (!whatsChangedSection) {
|
||||
core.setFailed(`Failed to build What's Changed section for ${tag}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webReleaseSection = await buildWebReleaseSection(tag);
|
||||
const releaseSummarySection = await buildReleaseSummarySection({
|
||||
tag,
|
||||
release,
|
||||
comparisonBase,
|
||||
whatsChangedSection,
|
||||
comparison,
|
||||
webReleaseSection,
|
||||
});
|
||||
|
||||
const body = [
|
||||
releaseSummarySection,
|
||||
whatsChangedSection,
|
||||
].filter(Boolean).join("\n\n");
|
||||
|
||||
if (clean(release.body) === clean(body)) {
|
||||
core.info(`Release notes for ${tag} are already up to date.`);
|
||||
} else {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Release Notes")
|
||||
.addRaw(`Updated release notes for ${tag}.`)
|
||||
.write();
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Rebuild Existing Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing GitHub Release tag whose assets and version image will be replaced.
|
||||
required: true
|
||||
type: string
|
||||
server_ref:
|
||||
description: Server commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
frontend_ref:
|
||||
description: komari-web commit, branch, or tag to build.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout workflow source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build selected frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ inputs.frontend_ref }}
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: komari-monitor/komari/.github/actions/restore-frontend@main
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: komari-monitor/komari/.github/actions/setup-zig@main
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
VERSION_HASH="$(git rev-parse HEAD)"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
|
||||
build-and-push-docker:
|
||||
needs: build-and-attach-binaries
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout server source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.server_ref }}
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Build and push version Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -1,9 +1,12 @@
|
||||
name: Publish Docker Image
|
||||
name: Publish Docker Image Manually
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Docker image tag and embedded version.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,7 +17,61 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-docker-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,54 +81,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/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: komari-docker-binaries
|
||||
path: .
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -92,8 +106,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
+114
-27
@@ -13,7 +13,38 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
|
||||
|
||||
steps:
|
||||
- id: release
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
frontend_ref=""
|
||||
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
|
||||
frontend_ref="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-frontend:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -52,6 +83,10 @@ jobs:
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
@@ -61,27 +96,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/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: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -89,12 +105,7 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
@@ -112,6 +123,12 @@ jobs:
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -124,3 +141,73 @@ jobs:
|
||||
# Upload the binary as a release asset using GitHub CLI
|
||||
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
needs: build-and-attach-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check whether this is the latest GitHub release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
is_latest="$(gh api graphql \
|
||||
-f 'query=query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { isLatest } } }' \
|
||||
-F owner="${{ github.repository_owner }}" \
|
||||
-F name="${{ github.event.repository.name }}" \
|
||||
-F tag="$TAG" \
|
||||
--jq '.data.repository.release.isLatest')"
|
||||
echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
|
||||
- name: Promote Docker latest tag
|
||||
if: steps.release.outputs.is_latest == 'true'
|
||||
env:
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
@@ -0,0 +1,293 @@
|
||||
name: Publish Snapshot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: snapshot-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate-ref:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure snapshot target is main
|
||||
run: |
|
||||
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Snapshot builds must run from refs/heads/main. Current ref: ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot builds must use the current main commit."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Generate snapshot version
|
||||
id: version
|
||||
run: |
|
||||
# Snapshot 版本号格式: Snapshot-yymmddhhMM (UTC+8)
|
||||
VERSION="Snapshot-$(date -u -d '+8 hours' +%y%m%d%H%M)"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
|
||||
build-frontend:
|
||||
needs: validate-ref
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build frontend artifact
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
artifact-name: komari-frontend
|
||||
|
||||
build-and-attach-binaries:
|
||||
needs: [prepare, build-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [windows, linux]
|
||||
goarch: [amd64, arm64, 386, riscv64]
|
||||
include:
|
||||
# Windows
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-windows-gnu
|
||||
binary_extension: .exe
|
||||
- goos: windows
|
||||
goarch: 386
|
||||
zig_target_triple: x86-windows-gnu
|
||||
binary_extension: .exe
|
||||
|
||||
# Linux
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
zig_target_triple: x86_64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
zig_target_triple: aarch64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: 386
|
||||
zig_target_triple: x86-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: riscv64
|
||||
zig_target_triple: riscv64-linux-musl
|
||||
binary_extension: ""
|
||||
- goos: linux
|
||||
goarch: loong64
|
||||
zig_target_triple: loongarch64-linux-musl
|
||||
binary_extension: ""
|
||||
|
||||
exclude:
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CC: zig cc -target ${{ matrix.zig_target_triple }}
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.binary_extension }}" != "" ]; then
|
||||
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
|
||||
fi
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
publish-snapshot-release:
|
||||
needs: [prepare, build-and-attach-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download all binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create snapshot prerelease and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "Snapshot version: ${VERSION}"
|
||||
ls -alR artifacts
|
||||
|
||||
# 如果同名 release/tag 已存在(例如重跑),先删除避免创建冲突
|
||||
if gh release view "${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release ${VERSION} 已存在,正在删除以重新创建..."
|
||||
gh release delete "${VERSION}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
fi
|
||||
|
||||
# 创建预发布并上传所有二进制文件
|
||||
gh release create "${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "${VERSION}" \
|
||||
--notes "Automated snapshot build (${VERSION}) from commit ${{ github.sha }}." \
|
||||
--prerelease \
|
||||
artifacts/komari-*
|
||||
|
||||
gh workflow run generate-release-notes.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref main \
|
||||
-f tag="${VERSION}"
|
||||
|
||||
gh release list \
|
||||
--repo "${{ github.repository }}" \
|
||||
--limit 1000 \
|
||||
--json tagName,isPrerelease \
|
||||
--jq '.[] | select(.isPrerelease and (.tagName | startswith("Snapshot-"))) | .tagName' |
|
||||
while IFS= read -r tag; do
|
||||
if [ -z "${tag}" ] || [ "${tag}" = "${VERSION}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Deleting old snapshot release ${tag}..."
|
||||
gh release delete "${tag}" --repo "${{ github.repository }}" --cleanup-tag --yes
|
||||
done
|
||||
shell: bash
|
||||
|
||||
build-and-push-docker:
|
||||
needs: [prepare, build-and-attach-binaries, publish-snapshot-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure commit is still current main
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin main
|
||||
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
|
||||
|
||||
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
|
||||
echo "Snapshot Docker publish skipped because a newer main commit exists."
|
||||
echo "Workflow SHA: ${GITHUB_SHA}"
|
||||
echo "Current main: ${MAIN_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: komari-linux-*
|
||||
path: .
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=snapshot
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
+3
-1
@@ -33,4 +33,6 @@ go.work.sum
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
komari
|
||||
/dist/
|
||||
.codex
|
||||
+3
-9
@@ -6,22 +6,16 @@ WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
|
||||
ENV GIN_MODE=release
|
||||
ENV KOMARI_DB_TYPE=sqlite
|
||||
ENV KOMARI_DB_FILE=/app/data/komari.db
|
||||
ENV KOMARI_DB_HOST=localhost
|
||||
ENV KOMARI_DB_PORT=3306
|
||||
ENV KOMARI_DB_USER=root
|
||||
ENV KOMARI_DB_PASS=
|
||||
ENV KOMARI_DB_NAME=komari
|
||||
ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
CMD ["/app/komari", "server"]
|
||||
CMD ["/app/komari", "server"]
|
||||
@@ -5,11 +5,16 @@
|
||||
|
||||

|
||||
|
||||
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
|
||||
[简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
|
||||
|
||||
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
|
||||
> [!WARNING]
|
||||
> Komari is a self-hosted monitoring and control program intended only for systems you own or are authorized to administer. Do not weaponize Komari, deploy it without consent, or use it for unauthorized access, persistence, command execution, or other abusive activity. For context on real-world abuse risks, see Huntress' analysis: [Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse).
|
||||
> Users are solely responsible for how they deploy and operate Komari. The developers do not accept responsibility for unauthorized or abusive use, or for any resulting consequences.
|
||||
> On Windows, when remote control is enabled, the client displays a Windows notification at each user login to remind the user that Komari is remote control software.
|
||||
|
||||
[文档/Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -17,9 +22,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
- **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
|
||||
## One-click Deployment
|
||||
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
@@ -29,87 +32,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
|
||||
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. Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. View the default username and password:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. Access `http://<your_server_ip>:25774` in your browser.
|
||||
|
||||
> [!NOTE]
|
||||
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
### 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. 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]
|
||||
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### 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. Build the backend:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
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
|
||||
```
|
||||
3. Run:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
The default listening port is `25774`. Access `http://localhost:25774`.
|
||||
|
||||
## Frontend Development Guide
|
||||
|
||||
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
## Client Agent Development Guide
|
||||
|
||||
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
|
||||
For Docker, binary, source-build, and update instructions, see the [installation guide](https://komari-document.pages.dev/en/install/quick-start).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -117,26 +40,8 @@ Issues and Pull Requests are welcome!
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
### 破碎工坊云
|
||||
|
||||
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
|
||||
### 🚀 Sponsored by SharonNetworks
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
|
||||
|
||||
### 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)
|
||||
| Acknowledgement | Details |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| Open-source community | Everyone who has submitted pull requests or created themes. |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Komari
|
||||
|
||||

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

|
||||
|
||||
[简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
|
||||
|
||||
[文档/Documentation](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel 应用商店
|
||||
|
||||
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
|
||||
|
||||
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鸣谢
|
||||
|
||||
| 赞助商 | 描述 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| 开源社区 | 提交 PR、制作主题的各位开发者 |
|
||||
@@ -1,42 +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) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// session-based authentication
|
||||
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()
|
||||
}
|
||||
}
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/patrickmn/go-cache"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
Records = cache.New(1*time.Minute, 1*time.Minute)
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
RequesterIp string
|
||||
}
|
||||
|
||||
var TerminalSessionsMutex = &sync.Mutex{}
|
||||
var TerminalSessions = make(map[string]*TerminalSession)
|
||||
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute).Unix()
|
||||
var records []models.Record
|
||||
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.Set(uuid, filtered, cache.DefaultExpiration)
|
||||
|
||||
// 计算平均报告并添加到记录列表
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Respond sends a standardized JSON response.
|
||||
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// RespondSuccess sends a success response with data.
|
||||
func RespondSuccess(c *gin.Context, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", "", data)
|
||||
}
|
||||
|
||||
// RespondSuccessMessage sends a success response with message and data.
|
||||
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", message, data)
|
||||
}
|
||||
|
||||
// RespondError sends an error response with message.
|
||||
func RespondError(c *gin.Context, httpStatus int, message string) {
|
||||
Respond(c, httpStatus, "error", message, nil)
|
||||
}
|
||||
func GetVersion(c *gin.Context) {
|
||||
RespondSuccess(c, gin.H{
|
||||
"version": utils.CurrentVersion,
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
publicPaths = []string{
|
||||
"/ping",
|
||||
"/api/public",
|
||||
"/api/login",
|
||||
"/api/me",
|
||||
"/api/oauth",
|
||||
"/api/oauth_callback",
|
||||
"/api/version",
|
||||
"/api/recent",
|
||||
"/api/admin", // 由AdminAuthMiddleware处理
|
||||
"/api/clients/", // 由TokenAuthMiddleware处理
|
||||
}
|
||||
)
|
||||
|
||||
func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 如果是公开的路径,直接放行
|
||||
for _, path := range publicPaths {
|
||||
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 如果不是 /api,直接放行
|
||||
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/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 func() bool {
|
||||
tempKey, err := c.Cookie("temp_key")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if tempKey != allowTempKey || allowTempKey == "" {
|
||||
return false
|
||||
}
|
||||
tempKeyExipreTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if tempKeyExipreTime < time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
c.SetCookie("temp_key", tempKey, int(tempKeyExipreTime-time.Now().Unix()), "/", "", false, false)
|
||||
return true
|
||||
}() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是私有站点,检查是否有 session
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
_, err = accounts.GetSession(session)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,89 +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) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
|
||||
// Step 1: Check query parameter for token
|
||||
token = c.Query("token")
|
||||
|
||||
// Step 2: If no token in query, check request body for non-GET requests
|
||||
if token == "" && c.Request.Method != http.MethodGet {
|
||||
// Read the body
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Restore the body for downstream handlers
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// Try to extract token from JSON body
|
||||
var bodyMap map[string]interface{}
|
||||
if len(bodyBytes) > 0 {
|
||||
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
|
||||
if tokenVal, exists := bodyMap["token"]; exists {
|
||||
if str, ok := tokenVal.(string); ok && str != "" {
|
||||
token = str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := checkTokenExists(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func checkTokenExists(token string) (bool, error) {
|
||||
_, err := clients.GetClientUUIDByToken(token)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package admin
|
||||
|
||||
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/records"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func AddClient(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||
uuid, token, err := clients.CreateClient()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
|
||||
return
|
||||
}
|
||||
uuid, token, err := clients.CreateClientWithName(req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
|
||||
}
|
||||
|
||||
func EditClient(c *gin.Context) {
|
||||
var req = make(map[string]interface{})
|
||||
uuid := c.Param("uuid")
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
|
||||
return
|
||||
}
|
||||
req["uuid"] = uuid
|
||||
err := clients.SaveClient(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func RemoveClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
err := clients.DeleteClient(uuid)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"error": "Failed to delete client" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
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) {
|
||||
if err := records.DeleteAll(); err != nil {
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Failed to delete Record" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func GetClient(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := clients.GetClientByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func ListClients(c *gin.Context) {
|
||||
cls, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cls)
|
||||
}
|
||||
|
||||
func GetClientToken(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
c.JSON(400, gin.H{
|
||||
"status": "error",
|
||||
"message": "Invalid or missing UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := clients.GetClientTokenByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func OrderWeight(c *gin.Context) {
|
||||
var req = make(map[string]int)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
for uuid, weight := range req {
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"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)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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/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"
|
||||
)
|
||||
|
||||
// 接受数据类型:
|
||||
// - command: string
|
||||
// - clients: []string (客户端 UUID 列表)
|
||||
func Exec(c *gin.Context) {
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
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.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 {
|
||||
offlineClients = append(offlineClients, uuid)
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
api.RespondError(c, 400, "No clients connected")
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, uuid := range onlineClients {
|
||||
var send struct {
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command"`
|
||||
TaskId string `json:"task_id"`
|
||||
}
|
||||
send.Message = "exec"
|
||||
send.Command = req.Command
|
||||
send.TaskId = taskId
|
||||
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.GetConnectedClients()[uuid]
|
||||
if client != nil {
|
||||
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")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
})
|
||||
if len(offlineClients) > 0 {
|
||||
for _, uuid := range offlineClients {
|
||||
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func contain(clients []string, uuid string) bool {
|
||||
// for _, client := range clients {
|
||||
// if client == uuid {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
@@ -1,49 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetLogs(c *gin.Context) {
|
||||
limit := c.Query("limit")
|
||||
if limit == "" {
|
||||
limit = "100" // Default to 100 logs if not specified
|
||||
}
|
||||
page := c.Query("page")
|
||||
if page == "" {
|
||||
page = "1" // Default to page 1 if not specified
|
||||
}
|
||||
// Convert limit and page to integers
|
||||
// If conversion fails, return an error
|
||||
limitInt, err := strconv.Atoi(limit)
|
||||
if err != nil || limitInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid limit: "+limit)
|
||||
return
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil || pageInt <= 0 {
|
||||
api.RespondError(c, 400, "Invalid page: "+page)
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var logs []models.Log
|
||||
// 添加分页:计算偏移量并限制数量
|
||||
offset := (pageInt - 1) * limitInt
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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"})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/notification"
|
||||
)
|
||||
|
||||
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
|
||||
func AddLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Metric string `json:"metric" binding:"required"`
|
||||
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
|
||||
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Interval > 4*60 || req.Interval <= 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
|
||||
return
|
||||
}
|
||||
if req.Ratio <= 0 || req.Ratio > 1 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint
|
||||
func DeleteLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
ID []uint `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.DeleteLoadNotification(req.ID); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint, updates map[string]interface{}
|
||||
func EditLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.EditLoadNotification(req.Notifications); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, notification := range req.Notifications {
|
||||
// notification.DeleteLoadNotification([]uint{notification.Id})
|
||||
// }
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications(c *gin.Context) {
|
||||
notifications, err := notification.GetAllLoadNotifications()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// POST body : []uuid
|
||||
func EnableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// POST body : []uuid
|
||||
func DisableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var notifications []models.OfflineNotification
|
||||
for _, uuid := range uuids {
|
||||
notifications = append(notifications, models.OfflineNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func EditOfflineNotification(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
if err := c.ShouldBindJSON(¬ifications); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
api.RespondError(c, 400, "At least one notification is required")
|
||||
return
|
||||
}
|
||||
for _, noti := range notifications {
|
||||
if noti.Client == "" {
|
||||
api.RespondError(c, 400, "Client UUID cannot be empty")
|
||||
return
|
||||
}
|
||||
if noti.GracePeriod <= 0 {
|
||||
api.RespondError(c, 400, "GracePeriod must be a positive integer")
|
||||
return
|
||||
}
|
||||
}
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
|
||||
}).
|
||||
Select("*").
|
||||
Create(notifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func ListOfflineNotifications(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(¬ifications).Error
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
user, err := accounts.GetUserBySession(session)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "No user found: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.SetCookie("binding_external_account", user.UUID, 3600, "/", "", false, true)
|
||||
c.Redirect(302, "/api/oauth")
|
||||
}
|
||||
func UnbindExternalAccount(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
user, err := accounts.GetUserBySession(session)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "No user found: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = accounts.UnbindExternalAccount(user.UUID)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
// POST body: clients []string, target, task_type string, interval int
|
||||
func AddPingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Target string `json:"target" binding:"required"`
|
||||
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
|
||||
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, gin.H{"task_id": taskID})
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint
|
||||
func DeletePingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
ID []uint `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.DeletePingTask(req.ID); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// POST body: id []uint, updates map[string]interface{}
|
||||
func EditPingTask(c *gin.Context) {
|
||||
var req struct {
|
||||
Tasks []*models.PingTask `json:"tasks" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.EditPingTask(req.Tasks); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, task := range req.Tasks {
|
||||
// tasks.DeletePingRecords([]uint{task.Id})
|
||||
// }
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllPingTasks(c *gin.Context) {
|
||||
tasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, tasks)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSessions(c *gin.Context) {
|
||||
|
||||
ss, err := accounts.GetAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
current, _ := c.Cookie("session_token")
|
||||
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
|
||||
}
|
||||
|
||||
func DeleteSession(c *gin.Context) {
|
||||
var req struct {
|
||||
Session string `json:"session" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := accounts.DeleteSession(req.Session)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteAllSession(c *gin.Context) {
|
||||
|
||||
err := accounts.DeleteAllSessions()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"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.GetAll()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
|
||||
// EditSettings 更新自定义配置
|
||||
func EditSettings(c *gin.Context) {
|
||||
cfg := make(map[string]interface{})
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SetMany(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
message = message[:len(message)-2]
|
||||
}
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetTasks(c *gin.Context) {
|
||||
dbTasks, err := tasks.GetAllTasks()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
var responseTasks []gin.H
|
||||
for _, t := range dbTasks {
|
||||
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
responseTasks = append(responseTasks, gin.H{
|
||||
"task_id": t.TaskId,
|
||||
"clients": t.Clients,
|
||||
"command": t.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, responseTasks)
|
||||
}
|
||||
|
||||
func GetTaskById(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
task, err := tasks.GetTaskByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
api.RespondError(c, 404, "Task not found")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
var filteredResults []gin.H
|
||||
for _, r := range results {
|
||||
filteredResults = append(filteredResults, gin.H{
|
||||
"client": r.Client,
|
||||
"result": r.Result,
|
||||
"exit_code": r.ExitCode,
|
||||
"finished_at": r.FinishedAt,
|
||||
"created_at": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": task.TaskId,
|
||||
"clients": task.Clients,
|
||||
"command": task.Command,
|
||||
"results": filteredResults,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTasksByClientId(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
tasks, err := tasks.GetTasksByClientId(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, tasks)
|
||||
}
|
||||
|
||||
func GetSpecificTaskResult(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
clientId := c.Param("uuid")
|
||||
if taskId == "" || clientId == "" {
|
||||
api.RespondError(c, 400, "Task ID and Client ID are required")
|
||||
return
|
||||
}
|
||||
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
api.RespondError(c, 404, "No result found for this task and client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, result)
|
||||
}
|
||||
|
||||
// Param: task_id
|
||||
func GetTaskResultsByTaskId(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
api.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No results found for this task")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
|
||||
func GetAllTaskResultByUUID(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
api.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetAllTasksResultByUUID(clientId)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
api.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, results)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"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/messageSender"
|
||||
)
|
||||
|
||||
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 message: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func TestGeoIp(c *gin.Context) {
|
||||
ip := c.Query("ip")
|
||||
if ip == "" {
|
||||
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
|
||||
ip = cfIP
|
||||
} else {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !cfg {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, GeoIpRecord)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
)
|
||||
|
||||
func UploadFavicon(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteFavicon(c *gin.Context) {
|
||||
if err := os.Remove("./data/favicon.ico"); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "Favicon not found")
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.UpdateDatabase(); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
)
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Name *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
SsoType *string `json:"sso_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Password == nil && req.Name == nil {
|
||||
api.RespondError(c, 400, "At least one field (username or password) must be provided")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && len(*req.Name) < 3 {
|
||||
api.RespondError(c, 400, "Username must be at least 3 characters long")
|
||||
return
|
||||
}
|
||||
if req.Password != nil && len(*req.Password) < 6 {
|
||||
api.RespondError(c, 400, "Password must be at least 6 characters long")
|
||||
return
|
||||
}
|
||||
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update user: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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)
|
||||
}()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// 如果超过这个时间没有收到任何消息,则认为连接已死
|
||||
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
|
||||
readWait = 11 * time.Second
|
||||
)
|
||||
|
||||
func UploadReport(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Println("Failed to read request body:", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
// Save report to database
|
||||
var report common.Report
|
||||
err = json.Unmarshal(bodyBytes, &report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(report.UUID, report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
|
||||
return
|
||||
}
|
||||
// Update report with method and token
|
||||
|
||||
ws.SetLatestReport(report.UUID, &report)
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
|
||||
func WebSocketReport(c *gin.Context) {
|
||||
// 升级ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
|
||||
return
|
||||
}
|
||||
conn := ws.NewSafeConn(unsafeConn)
|
||||
defer conn.Close()
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("Error reading message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 第一次数据拿token
|
||||
data := map[string]interface{}{}
|
||||
err = json.Unmarshal(message, &data)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
// it should ok,token was verfied in the middleware
|
||||
token := ""
|
||||
var errMsg string
|
||||
|
||||
// 优先检查查询参数中的 token
|
||||
token = c.Query("token")
|
||||
|
||||
// 如果 token 为空,返回错误
|
||||
if token == "" {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 接受新连接,并处理旧连接
|
||||
if 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
|
||||
}
|
||||
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
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.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 {
|
||||
reports, _ := api.Records.Get(uuid)
|
||||
if reports == nil {
|
||||
reports = []common.Report{}
|
||||
}
|
||||
if report.CPU.Usage < 0.01 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
reports = append(reports.([]common.Report), report)
|
||||
api.Records.Set(uuid, reports, cache.DefaultExpiration)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TaskResult(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
clientId, _ := clients.GetClientUUIDByToken(token)
|
||||
if clientId == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TaskId string `json:"task_id" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
FinishedAt time.Time `json:"finished_at" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"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 {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if uuid == "" || err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
|
||||
if (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)
|
||||
|
||||
switch ipType {
|
||||
case 0:
|
||||
cbi["ipv4"] = ip
|
||||
case 1:
|
||||
cbi["ipv6"] = ip
|
||||
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.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := clients.SaveClientInfo(cbi); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": err})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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 == "" {
|
||||
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] {
|
||||
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
records, _ := Records.Get(uuid)
|
||||
RespondSuccess(c, records)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"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 {
|
||||
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 != "" { // 开启了2FA
|
||||
if 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)
|
||||
auditlog.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)
|
||||
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
|
||||
c.Redirect(302, "/")
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMe(c *gin.Context) {
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
user, err := accounts.GetUserByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetMe(t *testing.T) {
|
||||
// 设置测试模式
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 创建测试用户
|
||||
user, err := accounts.CreateAccount("testuser", "password")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
uuid := user.UUID
|
||||
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionToken string
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "有效的会话",
|
||||
sessionToken: sessionToken,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "testuser",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的会话",
|
||||
sessionToken: "invalid_token",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无会话",
|
||||
sessionToken: "",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"username": "Guest",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建测试路由
|
||||
router := gin.New()
|
||||
router.GET("/me", GetMe)
|
||||
|
||||
// 创建测试请求
|
||||
req, _ := http.NewRequest("GET", "/me", nil)
|
||||
if tt.sessionToken != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: tt.sessionToken,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建响应记录器
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// 执行请求
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 断言状态码
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
// 解析响应体
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 断言响应体
|
||||
assert.Equal(t, tt.expectedBody, response)
|
||||
})
|
||||
}
|
||||
|
||||
// 清除测试数据
|
||||
accounts.DeleteAccountByUsername("testuser")
|
||||
accounts.DeleteAllSessions()
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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 {
|
||||
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]
|
||||
|
||||
RespondSuccess(c, clientList)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
p, e := database.GetPublicInfo()
|
||||
if e != nil {
|
||||
RespondError(c, 500, e.Error())
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, p)
|
||||
}
|
||||
-1616
File diff suppressed because it is too large.
Load diff
@@ -1,447 +0,0 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"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 != "" {
|
||||
found := slices.Contains(t.Clients, uuid)
|
||||
if !found {
|
||||
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,
|
||||
"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)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
type PublicPingTask struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Clients []string `json:"clients"`
|
||||
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,
|
||||
Type: task.Type,
|
||||
Interval: task.Interval,
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, publicTasks)
|
||||
}
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"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被控端离线!"))
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
|
||||
"message": "terminal",
|
||||
"request_id": id,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
|
||||
// 如果没有连接上,则关闭连接
|
||||
time.AfterFunc(30*time.Second, func() {
|
||||
TerminalSessionsMutex.Lock()
|
||||
if session.Agent == nil {
|
||||
if session.Browser != nil {
|
||||
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
|
||||
session.Browser.Close()
|
||||
}
|
||||
conn.Close()
|
||||
delete(TerminalSessions, id)
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
//auditlog.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
|
||||
}
|
||||
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()
|
||||
}
|
||||
+28
-6
@@ -1,14 +1,36 @@
|
||||
package flags
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DatabaseTypeSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite, mysql
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // MySQL/其他数据库主机地址
|
||||
DatabasePort string // MySQL/其他数据库端口
|
||||
DatabaseUser string // MySQL/其他数据库用户名
|
||||
DatabasePass string // MySQL/其他数据库密码
|
||||
DatabaseName string // MySQL/其他数据库名称
|
||||
|
||||
Listen string
|
||||
)
|
||||
|
||||
func NormalizeDatabaseType(databaseType string) string {
|
||||
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
|
||||
if databaseType == "" {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
return databaseType
|
||||
}
|
||||
|
||||
func ApplyDatabaseTypeNormalization() string {
|
||||
DatabaseType = NormalizeDatabaseType(DatabaseType)
|
||||
return DatabaseType
|
||||
}
|
||||
|
||||
func IsSQLite() bool {
|
||||
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
|
||||
}
|
||||
|
||||
func SupportedDatabaseTypes() string {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package flags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDatabaseType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": DatabaseTypeSQLite,
|
||||
"sqlite": DatabaseTypeSQLite,
|
||||
" SQLite ": DatabaseTypeSQLite,
|
||||
"SQLITE": DatabaseTypeSQLite,
|
||||
"postgres": "postgres",
|
||||
" postgres": "postgres",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeDatabaseType(input); got != want {
|
||||
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.Config{}).Where("id = ?", 1).
|
||||
Update("disable_password_login", false).Error
|
||||
})
|
||||
if err != nil {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+2
-19
@@ -17,17 +17,6 @@ func GetEnv(key, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// 从环境变量获取默认值
|
||||
var (
|
||||
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "Komari",
|
||||
Short: "Komari is a simple server monitoring tool",
|
||||
@@ -47,12 +36,6 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", "sqlite", "Database type (sqlite)")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "SQLite database file path")
|
||||
}
|
||||
+77
-385
@@ -1,55 +1,16 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
"github.com/komari-monitor/komari/api/admin/clipboard"
|
||||
log_api "github.com/komari-monitor/komari/api/admin/log"
|
||||
"github.com/komari-monitor/komari/api/admin/notification"
|
||||
"github.com/komari-monitor/komari/api/admin/test"
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/jsonRpc"
|
||||
public_api "github.com/komari-monitor/komari/api/public"
|
||||
"github.com/komari-monitor/komari/api/record"
|
||||
"github.com/komari-monitor/komari/api/task"
|
||||
appserver "github.com/komari-monitor/komari/internal/server"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"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"
|
||||
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"
|
||||
)
|
||||
|
||||
var (
|
||||
DynamicCorsEnabled bool = false
|
||||
)
|
||||
|
||||
var ServerCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the server",
|
||||
@@ -66,365 +27,96 @@ func init() {
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
// RunServer 按显式的生命周期阶段启动服务端。
|
||||
//
|
||||
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
|
||||
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
|
||||
func RunServer() {
|
||||
// #region 初始化
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
log.Fatalf("Failed to create theme directory: %v", err)
|
||||
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
|
||||
}
|
||||
InitDatabase()
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.GetManyAs[config.Legacy]()
|
||||
|
||||
installRequired, err := app.InstallRequired()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", 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 strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
|
||||
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
|
||||
if installRequired {
|
||||
completed, err := app.RunInstallGuide()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to run cloudflared: %v", err)
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
for {
|
||||
requirement, err := app.DatabaseMigrationRequired()
|
||||
if err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "database-migration-detection-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
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")
|
||||
if !requirement.Required() {
|
||||
break
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
// #region 公开路由
|
||||
r.POST("/api/login", api.Login)
|
||||
r.GET("/api/me", api.GetMe)
|
||||
r.GET("/api/clients", api.GetClients)
|
||||
r.GET("/api/nodes", api.GetNodesInformation)
|
||||
r.GET("/api/public", api.GetPublicSettings)
|
||||
r.GET("/api/oauth", api.OAuth)
|
||||
r.GET("/api/oauth_callback", api.OAuthCallback)
|
||||
r.GET("/api/logout", api.Logout)
|
||||
r.GET("/api/version", api.GetVersion)
|
||||
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
|
||||
|
||||
r.GET("/api/records/load", record.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", record.GetPingRecords)
|
||||
r.GET("/api/task/ping", task.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.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", client.EstablishConnection)
|
||||
tokenAuthrized.POST("/task/result", client.TaskResult)
|
||||
}
|
||||
// #region 管理员
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
|
||||
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)
|
||||
}
|
||||
// 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("/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", api.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
recordGroup.POST("/clear/all", admin.ClearAllRecords)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
|
||||
// clipboard
|
||||
clipboardGroup := adminAuthrized.Group("/clipboard")
|
||||
{
|
||||
clipboardGroup.GET("/:id", clipboard.GetClipboard)
|
||||
clipboardGroup.GET("", clipboard.ListClipboard)
|
||||
clipboardGroup.POST("", clipboard.CreateClipboard)
|
||||
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
|
||||
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
|
||||
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
|
||||
}
|
||||
|
||||
notificationGroup := adminAuthrized.Group("/notification")
|
||||
{
|
||||
// offline notifications
|
||||
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
loadAlertGroup := notificationGroup.Group("/load")
|
||||
{
|
||||
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
|
||||
loadAlertGroup.POST("/add", notification.AddLoadNotification)
|
||||
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
|
||||
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
|
||||
}
|
||||
}
|
||||
|
||||
pingTaskGroup := adminAuthrized.Group("/ping")
|
||||
{
|
||||
pingTaskGroup.GET("/", admin.GetAllPingTasks)
|
||||
pingTaskGroup.POST("/add", admin.AddPingTask)
|
||||
pingTaskGroup.POST("/delete", admin.DeletePingTask)
|
||||
pingTaskGroup.POST("/edit", admin.EditPingTask)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
|
||||
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" {
|
||||
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
|
||||
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
|
||||
// } else {
|
||||
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
|
||||
// }
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
completed, err := app.RunDatabaseMigration(requirement)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "run-database-migration", err)
|
||||
}
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
}
|
||||
}
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
tasks.ReloadPingSchedule()
|
||||
d_notification.ReloadLoadNotificationSchedule()
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
go notifier.CheckExpireScheduledWork()
|
||||
for {
|
||||
cfg, _ := config.GetManyAs[config.Legacy]()
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
auditlog.RemoveOldLogs()
|
||||
case <-minute.C:
|
||||
api.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
go notifier.CheckTraffic()
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
|
||||
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
|
||||
if err := app.ConnectMetricStoreWithRetry(); err != nil {
|
||||
completed, recoveryErr := app.RunMetricStoreRecovery(err)
|
||||
if recoveryErr != nil {
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
|
||||
}
|
||||
if !completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
cloudflared.Kill()
|
||||
}
|
||||
// 其余初始化阶段:任一步失败都不应继续对外服务。
|
||||
type stage struct {
|
||||
name string
|
||||
fn func() error
|
||||
}
|
||||
stages := []stage{
|
||||
{"init-stores", app.InitStores},
|
||||
{"init-providers", app.InitProviders},
|
||||
{"start-background", app.StartBackground},
|
||||
{"build-router", app.BuildRouter},
|
||||
}
|
||||
for _, s := range stages {
|
||||
if err := s.fn(); err != nil {
|
||||
// 已登记的资源尽力回收,再退出。
|
||||
_ = app.Shutdown()
|
||||
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Kill()
|
||||
if err := app.Run(); err != nil {
|
||||
if errors.Is(err, appserver.ErrRestartRequested) {
|
||||
logger.Infof("server", "Service stopped for restart: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Fatalf("server", "server exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
CPU bool `json:"cpu" gorm:"default:true"`
|
||||
GPU bool `json:"gpu" gorm:"default:true"`
|
||||
RAM bool `json:"ram" gorm:"default:true"`
|
||||
SWAP bool `json:"swap" gorm:"default:true"`
|
||||
LOAD bool `json:"load" gorm:"default:true"`
|
||||
UPTIME bool `json:"uptime" gorm:"default:true"`
|
||||
TEMP bool `json:"temp" gorm:"default:true"`
|
||||
OS bool `json:"os" gorm:"default:true"`
|
||||
DISK bool `json:"disk" gorm:"default:true"`
|
||||
NET bool `json:"net" gorm:"default:true"`
|
||||
PROCESS bool `json:"process" gorm:"default:true"`
|
||||
Connections bool `json:"connections" gorm:"default:true"`
|
||||
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClientInfo stores static client information
|
||||
// Deprecated: Use models.Client instead.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type IPAddress struct {
|
||||
Ipv4 string `json:"ipv4"`
|
||||
Ipv6 string `json:"ipv6"`
|
||||
}
|
||||
type Report struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
CPU CPUReport `json:"cpu"`
|
||||
Ram RamReport `json:"ram"`
|
||||
Swap RamReport `json:"swap"`
|
||||
Load LoadReport `json:"load"`
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
Method string `json:"method,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type RamReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type LoadReport struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type DiskReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type NetworkReport struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
TotalUp int64 `json:"totalUp"`
|
||||
TotalDown int64 `json:"totalDown"`
|
||||
}
|
||||
|
||||
type ConnectionsReport struct {
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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
@@ -1,374 +0,0 @@
|
||||
// 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",
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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"`
|
||||
// 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"`
|
||||
// 自定义美化
|
||||
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"
|
||||
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"
|
||||
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
|
||||
}
|
||||
*/
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const constantSalt = "06Wm4Jv1Hkxx"
|
||||
@@ -56,7 +54,10 @@ func hashPasswd(passwd string) string {
|
||||
}
|
||||
|
||||
func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
|
||||
}
|
||||
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
user = models.User{
|
||||
UUID: uuid.New().String(),
|
||||
@@ -71,7 +72,10 @@ func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
}
|
||||
|
||||
func DeleteAccountByUsername(username string) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
|
||||
}
|
||||
|
||||
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
|
||||
err = db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,39 +83,6 @@ func DeleteAccountByUsername(username string) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建默认管理员账户,使用环境变量 ADMIN_USERNAME 作为用户名,环境变量 ADMIN_PASSWORD 作为密码
|
||||
func CreateDefaultAdminAccount() (username, passwd string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
username = os.Getenv("ADMIN_USERNAME")
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
|
||||
passwd = os.Getenv("ADMIN_PASSWORD")
|
||||
if passwd == "" {
|
||||
passwd = utils.GeneratePassword()
|
||||
}
|
||||
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
SSOID: "",
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&user).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return username, passwd, nil
|
||||
}
|
||||
|
||||
func GetUserByUUID(uuid string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&user).Error
|
||||
@@ -171,7 +142,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
if sso_type != nil {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"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/internal/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
@@ -33,11 +33,11 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
sessionRecord := models.Session{
|
||||
UUID: uuid,
|
||||
Session: session,
|
||||
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
|
||||
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
|
||||
UserAgent: userAgent,
|
||||
Ip: ip,
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
LatestOnline: time.Now().UTC(),
|
||||
}
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
@@ -48,9 +48,9 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
_ = messageSender.SendNotification(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now(),
|
||||
Time: time.Now().UTC(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
@@ -73,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if time.Now().After(sessionRecord.Expires.ToTime()) {
|
||||
if time.Now().UTC().After(sessionRecord.Expires) {
|
||||
// 会话已过期,删除它
|
||||
_ = DeleteSession(session)
|
||||
return "", errors.New("session expired")
|
||||
@@ -111,25 +111,20 @@ func DeleteAllSessions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateLatestOnline(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
|
||||
}
|
||||
|
||||
func UpdateLatestUserAgent(session, userAgent string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
|
||||
}
|
||||
func UpdateLatestIp(session, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
|
||||
}
|
||||
|
||||
func UpdateLatest(session, useragent, ip string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
|
||||
"latest_online": time.Now(),
|
||||
"latest_online": time.Now().UTC(),
|
||||
"latest_user_agent": useragent,
|
||||
"latest_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package auditlog
|
||||
|
||||
import (
|
||||
"log"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
)
|
||||
|
||||
func Log(ip, uuid, message, msgType string) {
|
||||
now := time.Now()
|
||||
db := dbcore.GetDBInstance()
|
||||
logEntry := &models.Log{
|
||||
IP: ip,
|
||||
UUID: uuid,
|
||||
Message: message,
|
||||
MsgType: msgType,
|
||||
Time: models.FromTime(now),
|
||||
Time: time.Now().UTC(),
|
||||
}
|
||||
if err := db.Create(logEntry).Error; err != nil {
|
||||
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
|
||||
}
|
||||
db.Create(logEntry)
|
||||
}
|
||||
|
||||
func EventLog(eventType, message string) {
|
||||
@@ -28,8 +29,8 @@ func EventLog(eventType, message string) {
|
||||
// Delete logs older than 30 days
|
||||
func RemoveOldLogs() {
|
||||
db := dbcore.GetDBInstance()
|
||||
threshold := time.Now().AddDate(0, 0, -30)
|
||||
threshold := time.Now().UTC().AddDate(0, 0, -30)
|
||||
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
|
||||
log.Println("Failed to remove old logs:", err)
|
||||
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
|
||||
}
|
||||
}
|
||||
+103
-160
@@ -1,30 +1,20 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
|
||||
func DeleteClientConfig(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
@@ -34,79 +24,6 @@ func DeleteClient(clientUuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
|
||||
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if cbi.Name != "" {
|
||||
updates["name"] = cbi.Name
|
||||
}
|
||||
if cbi.CpuName != "" {
|
||||
updates["cpu_name"] = cbi.CpuName
|
||||
}
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
updates["os"] = cbi.OS
|
||||
}
|
||||
if cbi.GpuName != "" {
|
||||
updates["gpu_name"] = cbi.GpuName
|
||||
}
|
||||
if cbi.IPv4 != "" {
|
||||
updates["ipv4"] = cbi.IPv4
|
||||
}
|
||||
if cbi.IPv6 != "" {
|
||||
updates["ipv6"] = cbi.IPv6
|
||||
}
|
||||
if cbi.Region != "" {
|
||||
updates["region"] = cbi.Region
|
||||
}
|
||||
if cbi.Remark != "" {
|
||||
updates["remark"] = cbi.Remark
|
||||
}
|
||||
updates["mem_total"] = cbi.MemTotal
|
||||
updates["swap_total"] = cbi.SwapTotal
|
||||
updates["disk_total"] = cbi.DiskTotal
|
||||
updates["version"] = cbi.Version
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
// 转换为更新Client表
|
||||
client := models.Client{
|
||||
UUID: cbi.UUID,
|
||||
}
|
||||
|
||||
err := db.Model(&client).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(map[string]interface{}{
|
||||
"uuid": cbi.UUID,
|
||||
"name": cbi.Name,
|
||||
"cpu_name": cbi.CpuName,
|
||||
"arch": cbi.Arch,
|
||||
"cpu_cores": cbi.CpuCores,
|
||||
"os": cbi.OS,
|
||||
"gpu_name": cbi.GpuName,
|
||||
"ipv4": cbi.IPv4,
|
||||
"ipv6": cbi.IPv6,
|
||||
"region": cbi.Region,
|
||||
"remark": cbi.Remark,
|
||||
"mem_total": cbi.MemTotal,
|
||||
"swap_total": cbi.SwapTotal,
|
||||
"disk_total": cbi.DiskTotal,
|
||||
"version": cbi.Version,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
@@ -119,29 +36,75 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
|
||||
checkInt64 := func(name string, val float64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int8:
|
||||
return float64(typed), true
|
||||
case int16:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case uint:
|
||||
return float64(typed), true
|
||||
case uint8:
|
||||
return float64(typed), true
|
||||
case uint16:
|
||||
return float64(typed), true
|
||||
case uint32:
|
||||
return float64(typed), true
|
||||
case uint64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
|
||||
}
|
||||
|
||||
checkOptionalInt := func(name, key string, maxValue float64) error {
|
||||
value, exists := update[key]
|
||||
if !exists || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
numericValue, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s must be a valid number", name)
|
||||
}
|
||||
if numericValue < 0 || numericValue > maxValue {
|
||||
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
|
||||
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
|
||||
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -158,49 +121,6 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新客户端设置
|
||||
func UpdateClientConfig(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Save(&config).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientName(clientUUID, clientName string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// UpdateClientByUUID 更新指定 UUID 的客户端配置
|
||||
|
||||
func UpdateClientByUUID(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
func EditClientToken(clientUUID, token string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateClient 创建新客户端
|
||||
func CreateClient() (clientUUID, token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
@@ -211,14 +131,17 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -233,14 +156,17 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
@@ -265,19 +191,6 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
|
||||
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
|
||||
}
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -309,7 +222,37 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, exists := updates["expired_at"]; exists {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
updates["expired_at"] = nil
|
||||
case time.Time:
|
||||
updates["expired_at"] = typed.UTC()
|
||||
case *time.Time:
|
||||
if typed == nil {
|
||||
updates["expired_at"] = nil
|
||||
} else {
|
||||
updates["expired_at"] = typed.UTC()
|
||||
}
|
||||
case string:
|
||||
stamp, err := time.Parse(time.RFC3339Nano, typed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
|
||||
}
|
||||
updates["expired_at"] = stamp.UTC()
|
||||
default:
|
||||
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
|
||||
+2
-150
@@ -1,35 +1,14 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
// Report 表示客户端报告数据
|
||||
// SaveReport 保存报告数据
|
||||
func SaveReport(uuid string, data map[string]interface{}) (err error) {
|
||||
|
||||
report, err := ParseReport(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
@@ -40,20 +19,8 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report common.Report) error {
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
@@ -135,118 +102,3 @@ func ReportVerify(report common.Report) error {
|
||||
}
|
||||
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: models.FromTime(currentTime),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: averageGPUUsage, // 使用平均GPU使用率
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
SwapTotal: report.Swap.Total,
|
||||
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
|
||||
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
|
||||
Disk: report.Disk.Used,
|
||||
DiskTotal: report.Disk.Total,
|
||||
NetIn: report.Network.Down,
|
||||
NetOut: report.Network.Up,
|
||||
NetTotalUp: report.Network.TotalUp,
|
||||
NetTotalDown: report.Network.TotalDown,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
//Uptime: report.Uptime,
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// getString 从 map 中获取字符串
|
||||
func getString(data map[string]interface{}, key string) string {
|
||||
if val, ok := data[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getInt 从 map 中获取整数
|
||||
func getInt(data map[string]interface{}, key string) int {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getInt64 从 map 中获取 int64
|
||||
func getInt64(data map[string]interface{}, key string) int64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return int64(num)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFloat 从 map 中获取 float64
|
||||
func getFloat(data map[string]interface{}, key string) float64 {
|
||||
if val, ok := data[key]; ok {
|
||||
if num, ok := val.(float64); ok {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
*/
|
||||
@@ -1,6 +1,8 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
@@ -8,6 +10,8 @@ import (
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
cb.CreatedAt = time.Now().UTC()
|
||||
cb.UpdatedAt = cb.CreatedAt
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
@@ -24,6 +28,8 @@ func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
delete(fields, "created_at")
|
||||
fields["updated_at"] = time.Now().UTC()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
|
||||
+305
-323
@@ -2,10 +2,8 @@ package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,10 +11,11 @@ import (
|
||||
"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"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/internal/migrations"
|
||||
"github.com/komari-monitor/komari/internal/sqlitetune"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -166,331 +165,314 @@ func unzipToDir(zipPath, dstDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
log.Printf("Failed to read ClientInfo table: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, info := range clientInfos {
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新Client记录
|
||||
client.Name = info.Name
|
||||
client.CpuName = info.CpuName
|
||||
client.Virtualization = info.Virtualization
|
||||
client.Arch = info.Arch
|
||||
client.CpuCores = info.CpuCores
|
||||
client.OS = info.OS
|
||||
client.GpuName = info.GpuName
|
||||
client.IPv4 = info.IPv4
|
||||
client.IPv6 = info.IPv6
|
||||
client.Region = info.Region
|
||||
client.Remark = info.Remark
|
||||
client.PublicRemark = info.PublicRemark
|
||||
client.MemTotal = info.MemTotal
|
||||
client.SwapTotal = info.SwapTotal
|
||||
client.DiskTotal = info.DiskTotal
|
||||
client.Version = info.Version
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = models.FromTime(info.ExpiredAt)
|
||||
// Save updated Client record
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
log.Printf("Failed to update Client record: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Backup and rename old table after migration
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
log.Printf("Failed to backup ClientInfo table: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
|
||||
}
|
||||
|
||||
func MergeDatabase(db *gorm.DB) {
|
||||
if db.Migrator().HasTable("client_infos") {
|
||||
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
|
||||
mergeClientInfo(db)
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
}
|
||||
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 (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
|
||||
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
|
||||
// 也避免额外的裸文件依赖。
|
||||
const SystemVersionKey = "system_version"
|
||||
|
||||
const (
|
||||
mainSQLiteBusyTimeout = 5 * time.Second
|
||||
mainSQLiteCacheSizeKB = 8 * 1024
|
||||
mainSQLiteWALAutoCheckpoint = 256
|
||||
mainSQLiteJournalSizeLimit = 1 << 20
|
||||
)
|
||||
|
||||
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
|
||||
var versionID string
|
||||
|
||||
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
|
||||
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
|
||||
// 之前采集。
|
||||
var dbFileExistedAtStartup bool
|
||||
|
||||
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
|
||||
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
|
||||
func SetVersionID(id string) {
|
||||
versionID = id
|
||||
}
|
||||
|
||||
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
|
||||
func resolveDatabaseFile() string {
|
||||
dbFile := flags.DatabaseFile
|
||||
if dbFile == "" {
|
||||
dbFile = "./data/komari.db"
|
||||
}
|
||||
return dbFile
|
||||
}
|
||||
|
||||
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
|
||||
// ./data/backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
|
||||
//
|
||||
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
|
||||
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
|
||||
//
|
||||
// 触发规则:
|
||||
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
|
||||
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
|
||||
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
|
||||
// - 配置中版本与当前不同:版本升级,备份。
|
||||
// - 配置中版本与当前一致:无需备份。
|
||||
//
|
||||
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
|
||||
func backupOnVersionUpgrade() {
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
if versionRecorded && prevVersion == versionID {
|
||||
return
|
||||
}
|
||||
|
||||
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
|
||||
if !versionRecorded && !dbFileExistedAtStartup {
|
||||
writeVersionMarker()
|
||||
return
|
||||
}
|
||||
|
||||
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
|
||||
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
|
||||
// 避免备份出的库缺少仍留在 -wal 中的写入。
|
||||
if instance != nil {
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
|
||||
return
|
||||
}
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("upgrade-%s.zip", tsName))
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
|
||||
return
|
||||
}
|
||||
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
|
||||
|
||||
writeVersionMarker()
|
||||
}
|
||||
|
||||
// writeVersionMarker 将当前 versionID 写入配置库。
|
||||
func writeVersionMarker() {
|
||||
if err := config.Set(SystemVersionKey, versionID); err != nil {
|
||||
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSQLiteDSN(databaseFile string) string {
|
||||
if databaseFile == "" {
|
||||
databaseFile = "./data/komari.db"
|
||||
}
|
||||
|
||||
params := fmt.Sprintf("_busy_timeout=%d&_txlock=immediate", mainSQLiteBusyTimeout.Milliseconds())
|
||||
separator := "?"
|
||||
if strings.Contains(databaseFile, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(databaseFile, "file:") {
|
||||
return databaseFile + separator + params
|
||||
}
|
||||
|
||||
if databaseFile == ":memory:" {
|
||||
return "file::memory:?cache=shared&" + params
|
||||
}
|
||||
|
||||
return "file:" + filepath.ToSlash(databaseFile) + separator + params
|
||||
}
|
||||
|
||||
func mainSQLiteOptions() sqlitetune.Options {
|
||||
return sqlitetune.Options{
|
||||
BusyTimeout: mainSQLiteBusyTimeout,
|
||||
CacheSizeKB: mainSQLiteCacheSizeKB,
|
||||
MMapSizeBytes: 0,
|
||||
TempStoreMemory: false,
|
||||
CacheSpill: true,
|
||||
WALAutoCheckpoint: mainSQLiteWALAutoCheckpoint,
|
||||
JournalSizeLimitBytes: mainSQLiteJournalSizeLimit,
|
||||
Synchronous: sqlitetune.SynchronousNormal,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
|
||||
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
|
||||
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
|
||||
func Initialize() error {
|
||||
once.Do(func() {
|
||||
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./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: logutil.NewGormLogger(),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.DatabaseType {
|
||||
case "sqlite", "":
|
||||
// SQLite 连接
|
||||
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
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)
|
||||
}
|
||||
instance.Exec("VACUUM;")
|
||||
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.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{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
initErr = doInitialize()
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetDBInstance 返回全局数据库实例。
|
||||
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
|
||||
// 需要错误处理的启动流程应优先调用 Initialize()。
|
||||
func GetDBInstance() *gorm.DB {
|
||||
if err := Initialize(); err != nil {
|
||||
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
// Close 关闭底层数据库连接,供关闭流程调用。
|
||||
func Close() error {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := instance.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func doInitialize() error {
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 将当前数据快照保存到 ./data/backup/,并保留已有归档。
|
||||
backupDir := filepath.Join(".", "data", "backup")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().UTC().Format("20060102-150405")
|
||||
bakPath := filepath.Join(backupDir, fmt.Sprintf("pre-restore-%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除数据文件,但保留归档目录和待恢复的 backup.zip。
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}, backupDir: {}}); delErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
|
||||
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
|
||||
// gorm.Open 会创建空库。
|
||||
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
|
||||
dbFileExistedAtStartup = true
|
||||
}
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.NewGormLogger(),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// _txlock=immediate lets writes acquire their lock before reads can
|
||||
// turn into a lock-upgrade conflict. sqlitetune applies the remaining
|
||||
// per-connection PRAGMAs whenever database/sql opens a connection.
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
sqlDB, dbErr := sqlitetune.Open(dsn, mainSQLiteOptions())
|
||||
if dbErr != nil {
|
||||
return fmt.Errorf("open SQLite connection pool: %w", dbErr)
|
||||
}
|
||||
// SQLite has one writer. Keeping exactly one durable connection also
|
||||
// keeps connection-local cache and WAL limits stable for the main DB.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
sqlDB.SetConnMaxIdleTime(0)
|
||||
|
||||
instance, err = gorm.Open(sqlite.New(sqlite.Config{Conn: sqlDB}), logConfig)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
|
||||
}
|
||||
if err := instance.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
|
||||
logger.Errorf("dbcore", "Failed to checkpoint SQLite WAL at startup: %v", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
return fmt.Errorf("failed to run startup migrations: %w", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
|
||||
// 配置库就绪后、执行后续 AutoMigrate 之前:
|
||||
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
|
||||
backupOnVersionUpgrade()
|
||||
|
||||
// 自动迁移模型
|
||||
//
|
||||
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
|
||||
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
|
||||
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
|
||||
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
|
||||
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
|
||||
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
&models.PluginConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
); err != nil {
|
||||
logger.Errorf("dbcore", "Failed to create Session table, it may already exist: %v", err)
|
||||
}
|
||||
if err := instance.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskResult{},
|
||||
); err != nil {
|
||||
logger.Errorf("dbcore", "Failed to create Task and TaskResult table, it may already exist: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
var maintenanceMu sync.Mutex
|
||||
|
||||
// StorageSize returns the bytes occupied by the main SQLite database and its
|
||||
// WAL/SHM sidecar files.
|
||||
func StorageSize() (int64, error) {
|
||||
if !flags.IsSQLite() {
|
||||
return 0, errors.New("main database size is only available for SQLite")
|
||||
}
|
||||
return sqliteFileSetSize(resolveDatabaseFile())
|
||||
}
|
||||
|
||||
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
|
||||
func ReclaimSpace(ctx context.Context) error {
|
||||
if !flags.IsSQLite() {
|
||||
return errors.New("main database maintenance is only supported for SQLite")
|
||||
}
|
||||
|
||||
maintenanceMu.Lock()
|
||||
defer maintenanceMu.Unlock()
|
||||
|
||||
db, err := GetDBInstance().DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get main database connection: %w", err)
|
||||
}
|
||||
if err := checkpointSQLiteWAL(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
|
||||
return fmt.Errorf("vacuum main database: %w", err)
|
||||
}
|
||||
return checkpointSQLiteWAL(ctx, db)
|
||||
}
|
||||
|
||||
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
return fmt.Errorf("checkpoint main database WAL: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf(
|
||||
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
|
||||
logFrames,
|
||||
checkpointedFrames,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteFileSetSize(dsn string) (int64, error) {
|
||||
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
|
||||
if index := strings.IndexByte(path, '?'); index >= 0 {
|
||||
path = path[:index]
|
||||
}
|
||||
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
|
||||
return 0, errors.New("database is not backed by a local file")
|
||||
}
|
||||
|
||||
var total int64
|
||||
foundDatabase := false
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
info, err := os.Stat(path + suffix)
|
||||
switch {
|
||||
case err == nil:
|
||||
total += info.Size()
|
||||
if suffix == "" {
|
||||
foundDatabase = true
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
continue
|
||||
default:
|
||||
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
|
||||
}
|
||||
}
|
||||
if !foundDatabase {
|
||||
return 0, fmt.Errorf("database file %q does not exist", path)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
files := map[string]int{
|
||||
databasePath: 11,
|
||||
databasePath + "-wal": 7,
|
||||
databasePath + "-shm": 5,
|
||||
}
|
||||
for path, size := range files {
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
|
||||
t.Fatalf("write fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
|
||||
if err != nil {
|
||||
t.Fatalf("sqliteFileSetSize: %v", err)
|
||||
}
|
||||
if got != 23 {
|
||||
t.Fatalf("size = %d, want 23", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
|
||||
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
|
||||
if _, err := sqliteFileSetSize(dsn); err == nil {
|
||||
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/sqlitetune"
|
||||
)
|
||||
|
||||
func TestMainSQLiteConnectorAppliesBoundedSettings(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("open SQLite connector: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if got := sqlitePragmaInt(t, db, "busy_timeout"); got != mainSQLiteBusyTimeout.Milliseconds() {
|
||||
t.Fatalf("busy_timeout = %d, want %d", got, mainSQLiteBusyTimeout.Milliseconds())
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "synchronous"); got != 1 {
|
||||
t.Fatalf("synchronous = %d, want NORMAL (1)", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "cache_size"); got != -mainSQLiteCacheSizeKB {
|
||||
t.Fatalf("cache_size = %d, want %d", got, -mainSQLiteCacheSizeKB)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "mmap_size"); got != 0 {
|
||||
t.Fatalf("mmap_size = %d, want 0", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "temp_store"); got != 1 {
|
||||
t.Fatalf("temp_store = %d, want FILE (1)", got)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "cache_spill"); got == 0 {
|
||||
t.Fatal("cache_spill is disabled")
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "wal_autocheckpoint"); got != mainSQLiteWALAutoCheckpoint {
|
||||
t.Fatalf("wal_autocheckpoint = %d, want %d", got, mainSQLiteWALAutoCheckpoint)
|
||||
}
|
||||
if got := sqlitePragmaInt(t, db, "journal_size_limit"); got != mainSQLiteJournalSizeLimit {
|
||||
t.Fatalf("journal_size_limit = %d, want %d", got, mainSQLiteJournalSizeLimit)
|
||||
}
|
||||
|
||||
var journalMode string
|
||||
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
t.Fatalf("read journal_mode: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(journalMode, "wal") {
|
||||
t.Fatalf("journal_mode = %q, want WAL", journalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainSQLiteAutoCheckpointKeepsWALBounded(t *testing.T) {
|
||||
databasePath := filepath.Join(t.TempDir(), "komari.db")
|
||||
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("open SQLite connector: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if _, err := db.Exec("CREATE TABLE checkpoint_test (payload BLOB NOT NULL)"); err != nil {
|
||||
t.Fatalf("create checkpoint test table: %v", err)
|
||||
}
|
||||
|
||||
// This produces well beyond one auto-checkpoint interval. The checkpoint
|
||||
// below reports the WAL frame count observed before its own work begins.
|
||||
for range 1024 {
|
||||
if _, err := db.Exec("INSERT INTO checkpoint_test (payload) VALUES (zeroblob(4096))"); err != nil {
|
||||
t.Fatalf("insert checkpoint test row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var busy, logFrames, checkpointedFrames int
|
||||
if err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
|
||||
t.Fatalf("inspect automatic checkpoint: %v", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
t.Fatalf("automatic checkpoint left a busy WAL: %d", busy)
|
||||
}
|
||||
if logFrames >= 2*mainSQLiteWALAutoCheckpoint {
|
||||
t.Fatalf("WAL retained %d frames after %d-frame auto-checkpoint interval", logFrames, mainSQLiteWALAutoCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func sqlitePragmaInt(t *testing.T, db *sql.DB, pragma string) int64 {
|
||||
t.Helper()
|
||||
|
||||
var value int64
|
||||
if err := db.QueryRow("PRAGMA " + pragma).Scan(&value); err != nil {
|
||||
t.Fatalf("read %s: %v", pragma, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -5,15 +5,6 @@ import (
|
||||
"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
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package models
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
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:'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 配置
|
||||
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"`
|
||||
// 通知
|
||||
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,10 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Log struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);"`
|
||||
Message string `json:"message" gorm:"type:text;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
|
||||
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null;index:idx_logs_msg_type_time,priority:1"`
|
||||
Time time.Time `json:"time" gorm:"autoCreateTime;not null;index:idx_logs_time;index:idx_logs_msg_type_time,priority:2"`
|
||||
}
|
||||
@@ -8,4 +8,7 @@ const (
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
DReport = "DReport" // 日报
|
||||
WReport = "WReport" // 周报
|
||||
MReport = "MReport" // 月报
|
||||
)
|
||||
@@ -8,9 +8,9 @@ type MessageSenderProvider struct {
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event string `json:"event"`
|
||||
Event any `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Emoji string `json:"emoji"`
|
||||
Message any `json:"message"`
|
||||
Emoji any `json:"emoji"`
|
||||
}
|
||||
+65
-49
@@ -4,42 +4,44 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client represents a registered client device
|
||||
type Client struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
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"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
Hidden bool `json:"hidden" gorm:"default:false"`
|
||||
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
|
||||
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
@@ -51,8 +53,8 @@ type User struct {
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
@@ -62,17 +64,17 @@ type Session struct {
|
||||
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 LocalTime `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
|
||||
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
|
||||
Expires LocalTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
Expires time.Time `json:"expires" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time LocalTime `json:"time" gorm:"index"`
|
||||
Time time.Time `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
@@ -87,6 +89,8 @@ type Record struct {
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
|
||||
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
@@ -95,14 +99,14 @@ type Record struct {
|
||||
|
||||
// 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)
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time time.Time `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
@@ -110,9 +114,21 @@ type GPURecord struct {
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
|
||||
tests := map[string]interface{}{
|
||||
"bytes": []byte(`["a","b"]`),
|
||||
"text": `["a","b"]`,
|
||||
"empty": "",
|
||||
"nil": nil,
|
||||
}
|
||||
|
||||
for name, input := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var got StringArray
|
||||
if err := got.Scan(input); err != nil {
|
||||
t.Fatalf("scan StringArray: %v", err)
|
||||
}
|
||||
if input == "" || input == nil {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty StringArray, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected StringArray: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Notification 定义了通知相关的数据库模型
|
||||
type OfflineNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
@@ -19,5 +21,15 @@ type LoadNotification struct {
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// TrafficReportNotification 定义了流量定时报告的数据库模型
|
||||
type TrafficReportNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
|
||||
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
|
||||
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PingRecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time LocalTime `json:"time" gorm:"index;not null"`
|
||||
Time time.Time `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
// PingTask 表示一次延迟监测任务配置。
|
||||
type PingTask struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
|
||||
Target string `json:"target" gorm:"type:varchar(255);not null"`
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
|
||||
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
|
||||
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
}
|
||||
|
||||
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
|
||||
func (task PingTask) AppliesToClient(uuid string) bool {
|
||||
if uuid == "" {
|
||||
return false
|
||||
}
|
||||
for _, client := range task.Clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package models
|
||||
|
||||
// Plugin is the manifest of an installed plugin (komari-plugin.json).
|
||||
//
|
||||
// Name, Description and Author accept either a plain string or an i18n map
|
||||
// like {"zh_CN": "...", "en": "..."}, mirroring the theme manifest. The
|
||||
// backend passes the value through and the frontend resolves it against the
|
||||
// current locale.
|
||||
type Plugin struct {
|
||||
Name any `json:"name"`
|
||||
Short string `json:"short"` // directory name under data/plugin
|
||||
Description any `json:"description"`
|
||||
Author any `json:"author"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
Komari string `json:"komari"` // supported server version constraint, e.g. ">=0.0.1"
|
||||
Entry string `json:"entry"` // entry script, defaults to "script.js"
|
||||
Permissions PluginPermissions `json:"permissions"`
|
||||
Configuration Configuration `json:"configuration"` // declared config items, same shape as themes
|
||||
Pages []PluginPage `json:"pages,omitempty"` // injected admin pages
|
||||
}
|
||||
|
||||
// PluginPermissions declares the plugin capabilities that require admin
|
||||
// approval. Every field defaults to its zero value: nothing is granted
|
||||
// unless declared.
|
||||
//
|
||||
// The following capabilities are always granted and never require approval:
|
||||
// reading the plugin's own configuration (server.getConfig), registering
|
||||
// plugin-owned RPC methods (server.registerRPC), and reading/writing files
|
||||
// inside the plugin directory.
|
||||
type PluginPermissions struct {
|
||||
Node bool `json:"node"` // Node.js compatibility modules (runtime setting, not approval-relevant)
|
||||
AllowSystemRPC bool `json:"allowSystemRPC"` // server.call: call system RPC methods with admin authority
|
||||
AllowRoutes bool `json:"allowRoutes"` // server.route: register HTTP routes on the host engine
|
||||
AllowHooks bool `json:"allowHooks"` // server.hook: modify HTTP requests/responses and intercept WebSocket connections/frames
|
||||
AllowHTMLInject bool `json:"allowHTMLInject"` // server.injectHTML: embed CSS/JS into every HTML response
|
||||
AllowExec bool `json:"allowExec"` // child_process: execute child processes
|
||||
AllowListen bool `json:"allowListen"` // net/http servers: listen on local ports
|
||||
AllowAllFileAccess bool `json:"allowAllFileAccess"` // access files outside the plugin directory
|
||||
MaxHTTPBodyBytes int64 `json:"maxHTTPBodyBytes"` // runtime limit, not approval-relevant
|
||||
MaxChildOutputBytes int `json:"maxChildOutputBytes"` // runtime limit, not approval-relevant
|
||||
TimeoutSeconds int `json:"timeout"` // per-turn execution timeout in seconds, not approval-relevant
|
||||
}
|
||||
|
||||
// PageVisibility controls who can reach a plugin page.
|
||||
type PageVisibility string
|
||||
|
||||
const (
|
||||
// PageVisibilityAdmin limits the page to the admin sidebar.
|
||||
PageVisibilityAdmin PageVisibility = "admin"
|
||||
// PageVisibilityPublic serves the page through the public route without
|
||||
// navigation, mirroring the theme public behavior.
|
||||
PageVisibilityPublic PageVisibility = "public"
|
||||
)
|
||||
|
||||
// PageType controls how a plugin page is presented.
|
||||
type PageType string
|
||||
|
||||
const (
|
||||
// PageTypeIframe embeds a static file from the plugin directory.
|
||||
PageTypeIframe PageType = "iframe"
|
||||
// PageTypeRedirect navigates to an internal site path, mirroring the
|
||||
// theme redirect configuration. The URL must stay on the same origin.
|
||||
PageTypeRedirect PageType = "redirect"
|
||||
)
|
||||
|
||||
// PluginPage declares a page provided by the plugin. An iframe page is served
|
||||
// from the plugin directory (File); a redirect page navigates to an internal
|
||||
// site path (URL). Visibility defaults to admin so existing packages keep
|
||||
// their current behavior.
|
||||
type PluginPage struct {
|
||||
File string `json:"file"`
|
||||
Title any `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Type PageType `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Visibility PageVisibility `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// PluginConfiguration stores the saved configuration values of one plugin,
|
||||
// mirroring ThemeConfiguration.
|
||||
type PluginConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Task struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
@@ -13,6 +15,6 @@ type TaskResult struct {
|
||||
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 *LocalTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
|
||||
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
|
||||
}
|
||||
+112
-1
@@ -1,5 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeConfigurationManaged = "managed"
|
||||
ThemeConfigurationRaw = "raw"
|
||||
ThemeConfigurationRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
@@ -13,7 +26,7 @@ type Theme struct {
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
@@ -33,3 +46,101 @@ type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
func (t Theme) ConfigurationType() string {
|
||||
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
|
||||
if typ == "" {
|
||||
return ThemeConfigurationManaged
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func (t Theme) RawHTML() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRaw {
|
||||
return "", false
|
||||
}
|
||||
return configurationDataString(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) RedirectTarget() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRedirect {
|
||||
return "", false
|
||||
}
|
||||
return NormalizeThemeRedirectTarget(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) ValidateConfiguration() error {
|
||||
switch t.ConfigurationType() {
|
||||
case ThemeConfigurationManaged:
|
||||
return nil
|
||||
case ThemeConfigurationRaw:
|
||||
html, ok := t.RawHTML()
|
||||
if !ok || strings.TrimSpace(html) == "" {
|
||||
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
|
||||
}
|
||||
return nil
|
||||
case ThemeConfigurationRedirect:
|
||||
if _, ok := t.RedirectTarget(); !ok {
|
||||
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeThemeRedirectTarget(data any) (string, bool) {
|
||||
target, ok := configurationDataString(data)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cleanInputPath := parsed.Path
|
||||
if strings.HasPrefix(cleanInputPath, "/") {
|
||||
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
|
||||
} else {
|
||||
for strings.HasPrefix(cleanInputPath, "../") {
|
||||
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
|
||||
}
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(cleanInputPath, "/") {
|
||||
if segment == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath := cleanInputPath
|
||||
if cleanPath == "" {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
if cleanPath == "." {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := url.URL{
|
||||
Path: cleanPath,
|
||||
RawQuery: parsed.RawQuery,
|
||||
Fragment: parsed.Fragment,
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func configurationDataString(data any) (string, bool) {
|
||||
value, ok := data.(string)
|
||||
return value, ok
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThemeValidateConfiguration(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
theme Theme
|
||||
wantErr bool
|
||||
}{
|
||||
"managed empty data": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: ThemeConfigurationManaged},
|
||||
},
|
||||
},
|
||||
"missing type defaults to managed": {
|
||||
theme: Theme{},
|
||||
},
|
||||
"raw html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: "<!doctype html><title>raw</title>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"raw rejects empty html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: " ",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect relative path": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/dashboard?tab=nodes",
|
||||
},
|
||||
},
|
||||
},
|
||||
"redirect rejects absolute url": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "https://example.com",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect rejects parent traversal": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/../admin",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"unknown type": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: "hosted"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := tt.theme.ValidateConfiguration()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeThemeRedirectTarget(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
"leading slash": {
|
||||
input: "/nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"missing leading slash": {
|
||||
input: "nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"parent prefix points to root path": {
|
||||
input: "../settings",
|
||||
want: "/settings",
|
||||
ok: true,
|
||||
},
|
||||
"multiple parent prefixes point to root path": {
|
||||
input: "../../settings/site",
|
||||
want: "/settings/site",
|
||||
ok: true,
|
||||
},
|
||||
"query and fragment": {
|
||||
input: "nodes?id=1#cpu",
|
||||
want: "/nodes?id=1#cpu",
|
||||
ok: true,
|
||||
},
|
||||
"absolute parent traversal": {
|
||||
input: "/../admin",
|
||||
},
|
||||
"nested parent traversal": {
|
||||
input: "nodes/../admin",
|
||||
},
|
||||
"protocol relative": {
|
||||
input: "//example.com",
|
||||
},
|
||||
"backslash": {
|
||||
input: `\admin`,
|
||||
},
|
||||
"absolute url": {
|
||||
input: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := NormalizeThemeRedirectTarget(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("target = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
NowFunc: func() time.Time { return time.Now().UTC() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Log{}); err != nil {
|
||||
t.Fatalf("migrate log: %v", err)
|
||||
}
|
||||
|
||||
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
|
||||
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
t.Fatalf("create log: %v", err)
|
||||
}
|
||||
var loaded Log
|
||||
if err := db.First(&loaded, entry.ID).Error; err != nil {
|
||||
t.Fatalf("load log: %v", err)
|
||||
}
|
||||
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
|
||||
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(loaded)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&Client{}); err != nil {
|
||||
t.Fatalf("migrate client: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
|
||||
t.Fatalf("insert client: %v", err)
|
||||
}
|
||||
|
||||
var client Client
|
||||
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
|
||||
t.Fatalf("load client: %v", err)
|
||||
}
|
||||
if client.ExpiredAt != nil {
|
||||
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
|
||||
}
|
||||
encoded, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal client: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"expired_at":null`) {
|
||||
t.Fatalf("unexpected JSON: %s", encoded)
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,6 @@ func GetAllLoadNotifications() ([]models.LoadNotification, error) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
|
||||
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Enable: true,
|
||||
}})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
|
||||
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
|
||||
assert.Error(t, err)
|
||||
|
||||
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Daily: true,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, notifications, 1)
|
||||
assert.Equal(t, "client-a", notifications[0].Client)
|
||||
assert.True(t, notifications[0].Enable)
|
||||
}
|
||||
+5
-9
@@ -5,15 +5,6 @@ import (
|
||||
"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
|
||||
@@ -23,6 +14,11 @@ func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func DeleteOidcConfigByName(name string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
|
||||
+18
-485
@@ -1,506 +1,39 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func RecordOne(rec models.Record) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func RecordGPU(rec models.GPURecord) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
|
||||
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
|
||||
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
|
||||
//
|
||||
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
|
||||
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
|
||||
|
||||
func DeleteAll() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
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
|
||||
return metricstore.DeleteAllRecords(context.Background())
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取GPU记录数据
|
||||
// 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) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if len(long_term) == 0 {
|
||||
// 没有查到long_term,返回全部recentRecords
|
||||
records = append(records, recentRecords...)
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// 查到了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
|
||||
}
|
||||
}
|
||||
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
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
}
|
||||
|
||||
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
|
||||
// GetRecordMetricMaxByClientAndTime 获取单项监控指标在各时间桶内的最大值。
|
||||
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
|
||||
}
|
||||
|
||||
// 压缩数据库
|
||||
func CompactRecord() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := migrateOldRecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating old records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = migrateGPURecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating GPU records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.DatabaseType == "sqlite" {
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
log.Printf("Error vacuuming database: %v", err)
|
||||
}
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
//log.Printf("Record compaction completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOldRecords(db *gorm.DB) error {
|
||||
// 计算 4 小时前的时间
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
|
||||
// 查询 records 表中超过 4 小时的记录
|
||||
var records []models.Record
|
||||
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按 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
|
||||
}
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
for _, record := range records {
|
||||
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
|
||||
if _, ok := groupedRecords[key]; !ok {
|
||||
groupedRecords[key] = &groupData{}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if len(gpuRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按Client + DeviceIndex + 15分钟时间窗口分组
|
||||
type gpuGroupKey struct {
|
||||
Client string
|
||||
DeviceIndex int
|
||||
TimeSlot time.Time
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
|
||||
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
|
||||
|
||||
var _ = func() bool {
|
||||
// 确保 Test 环境中使用 sqlite 内存数据库
|
||||
return true
|
||||
}()
|
||||
|
||||
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
|
||||
// then running migrateOldRecords and verifying the aggregation and cleanup.
|
||||
func TestCompactRecord(t *testing.T) {
|
||||
const totalMinutes = 12*60 + 30
|
||||
now := time.Now()
|
||||
threshold := now.Add(-4 * time.Hour)
|
||||
|
||||
// 使用 sqlite 内存数据库并迁移表结构
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
expectedGroups := make(map[time.Time]struct{})
|
||||
expectedRemain := 0
|
||||
|
||||
// 插入数据
|
||||
for i := 0; i < totalMinutes; i++ {
|
||||
recTime := now.Add(-time.Duration(i) * time.Minute)
|
||||
rec := models.Record{Client: uuid, Time: models.FromTime(recTime), Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
|
||||
err := db.Create(&rec).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
if recTime.Before(threshold) {
|
||||
slot := recTime.Truncate(time.Hour)
|
||||
expectedGroups[slot] = struct{}{}
|
||||
} else {
|
||||
expectedRemain++
|
||||
}
|
||||
}
|
||||
|
||||
// 导出原始数据到 CSV
|
||||
os.MkdirAll("../../data", 0755)
|
||||
var origRecs []models.Record
|
||||
db.Order("time desc").Find(&origRecs)
|
||||
fOrig, err := os.Create("../../data/original.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fOrig.Close()
|
||||
wOrig := csv.NewWriter(fOrig)
|
||||
defer wOrig.Flush()
|
||||
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range origRecs {
|
||||
wOrig.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
|
||||
// 运行压缩(迁移)逻辑
|
||||
err = migrateOldRecords(db)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证 long-term 表中的聚合记录数
|
||||
var longCount int64
|
||||
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
|
||||
assert.Equal(t, int64(len(expectedGroups)), longCount)
|
||||
|
||||
// 验证原始表中剩余记录数
|
||||
var remainCount int64
|
||||
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
|
||||
assert.Equal(t, int64(expectedRemain), remainCount+1)
|
||||
|
||||
// 导出压缩后的数据到 CSV
|
||||
var compRecs []models.Record
|
||||
db.Table("records_long_term").Order("time desc").Find(&compRecs)
|
||||
fComp, err := os.Create("../../data/compressed.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fComp.Close()
|
||||
wComp := csv.NewWriter(fComp)
|
||||
defer wComp.Flush()
|
||||
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range compRecs {
|
||||
wComp.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
|
||||
db.Table("records").Order("time desc").Find(&compRecs)
|
||||
fComp, err = os.Create("../../data/compressed_records.csv")
|
||||
assert.NoError(t, err)
|
||||
defer fComp.Close()
|
||||
wComp = csv.NewWriter(fComp)
|
||||
defer wComp.Flush()
|
||||
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
|
||||
for _, r := range compRecs {
|
||||
wComp.Write([]string{
|
||||
r.Client,
|
||||
r.Time.ToTime().Format(time.RFC3339),
|
||||
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
|
||||
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
|
||||
strconv.FormatInt(r.Ram, 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
+148
-39
@@ -1,24 +1,46 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddPingTask(clients []string, name string, target, task_type string, interval int) (uint, error) {
|
||||
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
|
||||
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
normalizedClients := normalizePingClients(models.StringArray(clients))
|
||||
task := models.PingTask{
|
||||
Clients: clients,
|
||||
Name: name,
|
||||
Type: task_type,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
Clients: normalizedClients,
|
||||
DefaultOn: defaultOn,
|
||||
Name: name,
|
||||
Type: task_type,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append by id to avoid races between concurrent create requests.
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
@@ -26,6 +48,12 @@ func AddPingTask(clients []string, name string, target, task_type string, interv
|
||||
}
|
||||
|
||||
func DeletePingTask(id []uint) error {
|
||||
// The metric store is independent from the main database, so clean it first
|
||||
// to avoid leaving history that can no longer be addressed through the task.
|
||||
if err := DeletePingRecords(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
|
||||
if result.RowsAffected == 0 {
|
||||
@@ -35,10 +63,21 @@ func DeletePingTask(id []uint) error {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// EditPingTask 批量更新延迟监测任务配置。
|
||||
func EditPingTask(tasks []*models.PingTask) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, task := range tasks {
|
||||
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(task)
|
||||
task.Clients = normalizePingClients(task.Clients)
|
||||
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
|
||||
updates := map[string]interface{}{
|
||||
"name": task.Name,
|
||||
"clients": task.Clients,
|
||||
"all_clients": task.DefaultOn,
|
||||
"type": task.Type,
|
||||
"target": task.Target,
|
||||
"interval": task.Interval,
|
||||
}
|
||||
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
@@ -47,64 +86,134 @@ func EditPingTask(tasks []*models.PingTask) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
|
||||
func normalizePingClients(clients models.StringArray) models.StringArray {
|
||||
if clients == nil {
|
||||
return models.StringArray{}
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func GetAllPingTasks() ([]models.PingTask, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Find(&tasks).Error; err != nil {
|
||||
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func SavePingRecord(record models.PingRecord) error {
|
||||
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
|
||||
func GetPingTasksByClient(uuid string) []models.PingTask {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func DeletePingRecordsBefore(time time.Time) error {
|
||||
func UpdatePingTaskOrder(order map[uint]int) error {
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
|
||||
return err
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Validate all ids before changing any weights. The update response is
|
||||
// not a reliable existence check because some drivers report zero rows
|
||||
// when the new value equals the current value.
|
||||
ids := make([]uint, 0, len(order))
|
||||
for id := range order {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
|
||||
var existing int64
|
||||
if err := tx.Model(&models.PingTask{}).Where("id IN ?", ids).Count(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != int64(len(ids)) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
weight := order[id]
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
|
||||
// metric store,旧 ping_records 表不再参与。
|
||||
|
||||
func SavePingRecord(record models.PingRecord) error {
|
||||
return metricstore.WritePingRecord(context.Background(), record)
|
||||
}
|
||||
|
||||
func DeletePingRecords(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return result.Error
|
||||
return metricstore.DeletePingRecordsByTask(context.Background(), id)
|
||||
}
|
||||
|
||||
func DeleteAllPingRecords() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Exec("DELETE FROM ping_records")
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return result.Error
|
||||
return metricstore.DeleteAllPingRecords(context.Background())
|
||||
}
|
||||
|
||||
func ReloadPingSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var pingTasks []models.PingTask
|
||||
if err := db.Find(&pingTasks).Error; err != nil {
|
||||
pingTasks, err := GetAllPingTasks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return utils.ReloadPingSchedule(pingTasks)
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
|
||||
func AddDefaultOnClientUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.PingRecord
|
||||
dbQuery := db.Model(&models.PingRecord{})
|
||||
if uuid != "" {
|
||||
dbQuery = dbQuery.Where("client = ?", uuid)
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if taskId >= 0 {
|
||||
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
changed := false
|
||||
for _, task := range tasks {
|
||||
exists := false
|
||||
for _, c := range task.Clients {
|
||||
if c == uuid {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
next := append(models.StringArray{}, task.Clients...)
|
||||
next = append(next, uuid)
|
||||
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return records, nil
|
||||
if changed {
|
||||
return ReloadPingSchedule()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
|
||||
}
|
||||
+4
-15
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
|
||||
Result: "",
|
||||
ExitCode: nil,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
if len(taskResults) > 0 {
|
||||
@@ -57,13 +57,6 @@ func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func GetAllTasksResultByUUID(uuid string) ([]models.TaskResult, error) {
|
||||
var results []models.TaskResult
|
||||
if err := dbcore.GetDBInstance().Where("client = ?", uuid).Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func GetAllTasks() ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
|
||||
@@ -79,21 +72,17 @@ func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
func DeleteTaskByTaskId(taskId string) error {
|
||||
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
|
||||
}
|
||||
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
Where("task_id = ? AND client = ?", taskId, clientId).
|
||||
Updates(map[string]interface{}{
|
||||
"result": result,
|
||||
"exit_code": exitCode,
|
||||
"finished_at": timestamp,
|
||||
"finished_at": timestamp.UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func ClearTaskResultsByTimeBefore(before time.Time) error {
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.Format(time.RFC3339)).Delete(&models.TaskResult{}).Error
|
||||
return dbcore.GetDBInstance().Where("created_at < ?", before.UTC()).Delete(&models.TaskResult{}).Error
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
taskID := "task-cleanup-time"
|
||||
if err := db.Create(&models.Task{TaskId: taskID, Command: "true"}).Error; err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
cutoff := time.Date(2026, 7, 17, 12, 0, 0, 123456789, time.UTC)
|
||||
results := []models.TaskResult{
|
||||
{TaskId: taskID, Client: "old", CreatedAt: cutoff.Add(-time.Nanosecond)},
|
||||
{TaskId: taskID, Client: "boundary", CreatedAt: cutoff},
|
||||
{TaskId: taskID, Client: "new", CreatedAt: cutoff.Add(time.Nanosecond)},
|
||||
}
|
||||
if err := db.Create(&results).Error; err != nil {
|
||||
t.Fatalf("create task results: %v", err)
|
||||
}
|
||||
|
||||
localCutoff := cutoff.In(time.FixedZone("UTC+8", 8*60*60))
|
||||
if err := ClearTaskResultsByTimeBefore(localCutoff); err != nil {
|
||||
t.Fatalf("clear task results: %v", err)
|
||||
}
|
||||
var remaining []models.TaskResult
|
||||
if err := db.Where("task_id = ?", taskID).Order("created_at").Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("load remaining results: %v", err)
|
||||
}
|
||||
if len(remaining) != 2 || remaining[0].Client != "boundary" || remaining[1].Client != "new" {
|
||||
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
tasks := []models.PingTask{
|
||||
{Name: "third", Weight: 2, Type: "icmp", Target: "third.example", Interval: 60},
|
||||
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "second", Weight: 0, Type: "icmp", Target: "second.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&tasks).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
ordered, err := GetAllPingTasks()
|
||||
if err != nil {
|
||||
t.Fatalf("get ordered ping tasks: %v", err)
|
||||
}
|
||||
if len(ordered) != len(tasks) {
|
||||
t.Fatalf("ordered ping task count = %d, want %d", len(ordered), len(tasks))
|
||||
}
|
||||
if ordered[0].Id != tasks[1].Id || ordered[1].Id != tasks[2].Id || ordered[2].Id != tasks[0].Id {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_update_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "second", Weight: 1, Type: "icmp", Target: "second.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
if err := UpdatePingTaskOrder(map[uint]int{
|
||||
items[0].Id: 0,
|
||||
items[1].Id: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("update ping task order: %v", err)
|
||||
}
|
||||
|
||||
var first, second models.PingTask
|
||||
if err := db.First(&first, items[0].Id).Error; err != nil {
|
||||
t.Fatalf("load first ping task: %v", err)
|
||||
}
|
||||
if err := db.First(&second, items[1].Id).Error; err != nil {
|
||||
t.Fatalf("load second ping task: %v", err)
|
||||
}
|
||||
if first.Weight != 0 || second.Weight != 2 {
|
||||
t.Fatalf("weights = [%d, %d], want [0, 2]", first.Weight, second.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_client_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "third", Weight: 2, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "third.example", Interval: 60},
|
||||
{Name: "first", Weight: 0, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "other", Weight: 1, Clients: models.StringArray{"node-b"}, Type: "icmp", Target: "other.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
ordered := GetPingTasksByClient("node-a")
|
||||
if len(ordered) != 2 || ordered[0].Id != items[1].Id || ordered[1].Id != items[0].Id {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d]", ordered, items[1].Id, items[0].Id)
|
||||
}
|
||||
}
|
||||
+16
-40
@@ -1,20 +1,22 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (any, error) {
|
||||
cstPtr, err := config.GetManyAs[models.Config]()
|
||||
func GetPublicInfo() (map[string]interface{}, error) {
|
||||
cstPtr, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,24 +44,6 @@ func GetPublicInfo() (any, error) {
|
||||
if !hasKey("o_auth_provider") {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if !hasKey("geo_ip_enabled") {
|
||||
cst.GeoIpEnabled = true
|
||||
}
|
||||
if !hasKey("geo_ip_provider") {
|
||||
cst.GeoIpProvider = "ip-api"
|
||||
}
|
||||
if !hasKey("notification_method") {
|
||||
cst.NotificationMethod = "none"
|
||||
}
|
||||
if !hasKey("record_enabled") {
|
||||
cst.RecordEnabled = true
|
||||
}
|
||||
if !hasKey("record_preserve_time") {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if !hasKey("ping_record_preserve_time") {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
|
||||
// Fallback defaults if we couldn't enumerate keys.
|
||||
if allErr != nil {
|
||||
@@ -69,20 +53,11 @@ func GetPublicInfo() (any, error) {
|
||||
if cst.Description == "" {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if cst.Theme == "" {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if cst.OAuthProvider == "" {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if cst.RecordPreserveTime == 0 {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if cst.PingRecordPreserveTime == 0 {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
}
|
||||
|
||||
retention, err := metricstore.GetRetentionSummary(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
@@ -92,7 +67,7 @@ func GetPublicInfo() (any, error) {
|
||||
tc_data := gin.H{}
|
||||
err = json.Unmarshal([]byte(tc.Data), &tc_data)
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
logger.Infof("database", "%v", err)
|
||||
}
|
||||
// Try to load theme declaration file and merge defaults for managed configuration
|
||||
// Theme declarations live in ./data/theme/<short>/komari-theme.json
|
||||
@@ -155,11 +130,12 @@ func GetPublicInfo() (any, error) {
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"oauth_provider": cst.OAuthProvider,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
"record_enabled": cst.RecordEnabled,
|
||||
"record_preserve_time": cst.RecordPreserveTime,
|
||||
"ping_record_preserve_time": cst.PingRecordPreserveTime,
|
||||
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
|
||||
"record_enabled": retention.AllPositive, // 兼容旧版本主题
|
||||
"record_preserve_time": retention.MaxDays * 24,
|
||||
"ping_record_preserve_time": retention.MaxDays * 24,
|
||||
"private_site": cst.PrivateSite,
|
||||
"visitor_audit_enabled": cst.VisitorAuditEnabled,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
}, nil
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# Komari
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
|
||||
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
|
||||
|
||||
## 特徴
|
||||
|
||||
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
|
||||
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
|
||||
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 0. クラウドホスティングによるワンクリック展開
|
||||
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel アプリストア
|
||||
|
||||
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
|
||||
|
||||
### 1. ワンクリックインストールスクリプトを使用する
|
||||
|
||||
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展開
|
||||
|
||||
1. データディレクトリを作成します:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. Dockerコンテナを実行します:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. デフォルトのユーザー名とパスワードを表示します:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
|
||||
|
||||
> [!NOTE]
|
||||
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
|
||||
|
||||
### 3. バイナリファイル展開
|
||||
|
||||
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komariを実行します:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
|
||||
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
|
||||
|
||||
> [!NOTE]
|
||||
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
|
||||
|
||||
### 手動ビルド
|
||||
|
||||
#### 依存関係
|
||||
|
||||
- Go 1.18+ および Node.js 20+(手動ビルド用)
|
||||
|
||||
1. フロントエンドの静的ファイルをビルドします:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. バックエンドをビルドします:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
3. 実行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
|
||||
|
||||
## フロントエンド開発ガイド
|
||||
|
||||
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## クライアントエージェント開発ガイド
|
||||
|
||||
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢献
|
||||
|
||||
IssueやPull Requestを歓迎します!
|
||||
|
||||
## 謝辞
|
||||
|
||||
### 破碎工坊云 (Crash Work)
|
||||
|
||||
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
|
||||
|
||||
### 🚀 SharonNetworks スポンサー
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
|
||||
|
||||
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
|
||||
|
||||
### オープンソースコミュニティ
|
||||
|
||||
PR を送ってくれた方、テーマを作成してくれた全ての開発者
|
||||
|
||||
—— そして:こんなに暇でいられる自分に感謝
|
||||
|
||||
## Star履歴
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user