mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-12 01:00:51 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8165440839 | ||
|
|
356f6903a3 | ||
|
|
f693eaf764 | ||
|
|
35b49222c0 | ||
|
|
8b832e7ce5 | ||
|
|
fca23546f1 | ||
|
|
e187116d7a | ||
|
|
9af4cbb3f9 | ||
|
|
2a3e458f63 | ||
|
|
ca350be7a5 | ||
|
|
0b8f2fc176 | ||
|
|
c2d8008abc | ||
|
|
56b14320e8 | ||
|
|
76f6442fbe | ||
|
|
446311f343 | ||
|
|
475b2352db | ||
|
|
ebb024a361 | ||
|
|
6c608b22f6 | ||
|
|
c70dae1396 | ||
|
|
b980857d3e | ||
|
|
2b8280cc48 | ||
|
|
510d672f24 | ||
|
|
ea53facb98 | ||
|
|
a7a6a7b410 | ||
|
|
bf9ad3357e | ||
|
|
48c6030393 | ||
|
|
026b12e8ae | ||
|
|
90aad4b48a | ||
|
|
84ec0a9d87 | ||
|
|
801d9415ed | ||
|
|
f8b4e90d47 | ||
|
|
5b5cf32a28 | ||
|
|
0413c9aa80 | ||
|
|
1672bac3de | ||
|
|
24322b613d | ||
|
|
3729269fa3 | ||
|
|
0c8095ae11 | ||
|
|
535acc99b8 | ||
|
|
01912be70e | ||
|
|
df62a87591 | ||
|
|
a3faf856e0 | ||
|
|
39f849d03e | ||
|
|
53b3d7dd71 | ||
|
|
eab4b513be |
No files matched your search
@@ -1,55 +0,0 @@
|
||||
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
|
||||
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
|
||||
git clone --depth=1 "${{ inputs.repository }}" komari-web
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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"
|
||||
+25
-18
@@ -1,26 +1,16 @@
|
||||
name: Build Binaries on PR
|
||||
name: Build Binaries on Main Push and 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:
|
||||
@@ -68,8 +58,20 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -77,7 +79,12 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
@@ -94,7 +101,7 @@ jobs:
|
||||
fi
|
||||
VERSION="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Development Environment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
# push:
|
||||
# branches:
|
||||
# - dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -16,11 +16,18 @@ jobs:
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
# mkdir -p public/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -33,7 +40,7 @@ jobs:
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION_SHA="${{ github.sha }}"
|
||||
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
|
||||
go build -ldflags "-X github.com/komari-monitor/komari/internal/conf.Version=dev -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_SHA}" -o komari .
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -11,61 +11,7 @@ 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
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Upload Docker binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: komari-linux-*
|
||||
|
||||
build-and-push-multi-arch:
|
||||
needs: build-docker-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -75,11 +21,47 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
run: |
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
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();
|
||||
@@ -1,12 +1,9 @@
|
||||
name: Publish Docker Image Manually
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Docker image tag and embedded version.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,61 +14,7 @@ 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
|
||||
@@ -81,11 +24,47 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Docker binaries
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
name: komari-docker-binaries
|
||||
path: .
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig
|
||||
run: |
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
|
||||
- name: Build static binaries for multi-arch
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GIN_MODE: release
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
echo "Building for linux/arm64..."
|
||||
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -106,7 +85,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.inputs.version }}
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
|
||||
@@ -13,20 +13,7 @@ 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-and-attach-binaries:
|
||||
needs: build-frontend
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -74,8 +61,20 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore frontend artifact
|
||||
uses: ./.github/actions/restore-frontend
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "23"
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -83,7 +82,12 @@ jobs:
|
||||
go-version: "1.23"
|
||||
|
||||
- name: Install Zig cross-compiler toolchain
|
||||
uses: ./.github/actions/setup-zig
|
||||
run: |
|
||||
sudo apt-get update # Update apt package lists
|
||||
sudo apt-get install -y build-essential curl
|
||||
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
|
||||
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
|
||||
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
@@ -99,13 +103,7 @@ jobs:
|
||||
fi
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
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 }}*
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
@@ -119,54 +117,3 @@ 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: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -1,289 +0,0 @@
|
||||
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: ""
|
||||
|
||||
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
|
||||
+1
-2
@@ -33,5 +33,4 @@ go.work.sum
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
.codex
|
||||
komari
|
||||
+2
-13
@@ -6,18 +6,7 @@ WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
RUN set -eux; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) cloudflared_arch="amd64" ;; \
|
||||
386) cloudflared_arch="386" ;; \
|
||||
arm64) cloudflared_arch="arm64" ;; \
|
||||
arm) cloudflared_arch="arm" ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflared_arch}" -o /usr/local/bin/cloudflared; \
|
||||
chmod +x /usr/local/bin/cloudflared
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
@@ -35,4 +24,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
CMD ["/app/komari", "server"]
|
||||
CMD ["/app/komari", "server"]
|
||||
@@ -9,11 +9,6 @@
|
||||
|
||||
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.
|
||||
|
||||
> [!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
|
||||
@@ -30,10 +25,6 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel App Store
|
||||
|
||||
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
|
||||
|
||||
### 1. Use the One-click Install Script
|
||||
|
||||
Suitable for distributions using systemd (Ubuntu, Debian...).
|
||||
@@ -84,7 +75,7 @@ sudo ./install-komari.sh
|
||||
|
||||
#### Dependencies
|
||||
|
||||
- Go 1.18+ and Node.js 20+ (for manual build)
|
||||
- Go 1.24+ and Node.js 20+ (for manual build)
|
||||
|
||||
1. Build the frontend static files:
|
||||
```bash
|
||||
@@ -98,7 +89,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
|
||||
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
@@ -122,10 +113,6 @@ Issues and Pull Requests are welcome!
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
### 破碎工坊云
|
||||
|
||||
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
@@ -136,7 +123,7 @@ Issues and Pull Requests are welcome!
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供 Tbps 级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
|
||||
|
||||
### The open source software community
|
||||
|
||||
|
||||
+29
-21
@@ -1,12 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/app"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -25,25 +27,31 @@ var ChpasswdCmd = &cobra.Command{
|
||||
cmd.Help()
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
|
||||
cmd.Println("Database file does not exist.")
|
||||
return
|
||||
}
|
||||
user := &models.User{}
|
||||
dbcore.GetDBInstance().Model(&models.User{}).First(user)
|
||||
cmd.Println("Changing password for user:", user.Username)
|
||||
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
|
||||
a := app.New().With(dbcore.NewDBModule())
|
||||
err := a.RunWith(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := os.Stat(conf.Conf.Database.DatabaseFile); os.IsNotExist(err) {
|
||||
cmd.Println("Database file does not exist.")
|
||||
return nil
|
||||
}
|
||||
user := &models.User{}
|
||||
dbcore.GetDBInstance().Model(&models.User{}).First(user)
|
||||
cmd.Println("Changing password for user:", user.Username)
|
||||
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
return nil
|
||||
}
|
||||
cmd.Println("Password changed successfully, new password:", NewPassword)
|
||||
|
||||
if err := accounts.DeleteAllSessions(); err != nil {
|
||||
cmd.Println("Unable to force logout of other devices:", err)
|
||||
return nil
|
||||
}
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
cmd.Println("Password changed successfully, new password:", NewPassword)
|
||||
|
||||
if err := accounts.DeleteAllSessions(); err != nil {
|
||||
cmd.Println("Unable to force logout of other devices:", err)
|
||||
return
|
||||
}
|
||||
|
||||
cmd.Println("Please restart the server to apply the changes.")
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -1,10 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/app"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,10 +16,13 @@ var Disable2FA = &cobra.Command{
|
||||
Short: "Force disable 2FA",
|
||||
Long: `Force disable 2FA`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.User{}).Where("two_factor != ?", "").
|
||||
Update("two_factor", "").Error
|
||||
a := app.New().With(dbcore.NewDBModule())
|
||||
err := a.RunWith(context.Background(), func(ctx context.Context) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.User{}).Where("two_factor != ?", "").
|
||||
Update("two_factor", "").Error
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
|
||||
+2
-37
@@ -1,41 +1,6 @@
|
||||
package flags
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DatabaseTypeSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // 保留的兼容参数,当前未使用
|
||||
DatabasePort string // 保留的兼容参数,当前未使用
|
||||
DatabaseUser string // 保留的兼容参数,当前未使用
|
||||
DatabasePass string // 保留的兼容参数,当前未使用
|
||||
DatabaseName string // 保留的兼容参数,当前未使用
|
||||
|
||||
Listen string
|
||||
ConfigFile string // 配置文件路径
|
||||
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
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,4 +1,4 @@
|
||||
package nezha
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,17 +11,17 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/pkg/config"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
apiClient "github.com/komari-monitor/komari/web/api/client"
|
||||
apiClient "github.com/komari-monitor/komari/internal/api_v1/client"
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/geoip"
|
||||
"github.com/komari-monitor/komari/internal/notifier"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
|
||||
"github.com/komari-monitor/komari/web/nezha/proto"
|
||||
"github.com/komari-monitor/komari/internal/nezha/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/metadata"
|
||||
@@ -187,10 +187,10 @@ func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSy
|
||||
}
|
||||
// presence start
|
||||
connID := time.Now().UnixNano()
|
||||
agent_runtime.SetPresence(uuid, connID, true)
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
go notifier.OnlineNotification(uuid, connID)
|
||||
defer func() {
|
||||
agent_runtime.SetPresence(uuid, connID, false)
|
||||
ws.SetPresence(uuid, connID, false)
|
||||
notifier.OfflineNotification(uuid, connID)
|
||||
}()
|
||||
for {
|
||||
@@ -202,7 +202,7 @@ func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSy
|
||||
return err
|
||||
}
|
||||
// refresh presence TTL on every frame
|
||||
agent_runtime.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
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)
|
||||
@@ -223,8 +223,8 @@ func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskSer
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
connID := time.Now().UnixNano()
|
||||
agent_runtime.SetPresence(uuid, connID, true)
|
||||
defer agent_runtime.SetPresence(uuid, connID, false)
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
defer ws.SetPresence(uuid, connID, false)
|
||||
// receive results in background
|
||||
recvErr := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -239,7 +239,7 @@ func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskSer
|
||||
return
|
||||
}
|
||||
// refresh presence TTL when result received
|
||||
agent_runtime.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
}
|
||||
}()
|
||||
// send heartbeat tasks periodically
|
||||
@@ -287,38 +287,36 @@ func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
|
||||
return int64(v)
|
||||
}
|
||||
c := models.Client{
|
||||
UUID: uuid,
|
||||
Token: secret,
|
||||
Name: "nezha_" + uuid[0:8],
|
||||
CpuName: cpuName,
|
||||
Arch: h.Arch,
|
||||
CpuCores: len(h.Cpu),
|
||||
CpuPhysicalCores: 0, // Nezha 协议未提供物理核心数,0 表示未知
|
||||
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,
|
||||
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,
|
||||
"cpu_physical_cores": c.CpuPhysicalCores,
|
||||
"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(),
|
||||
"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"}},
|
||||
@@ -326,7 +324,7 @@ func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
|
||||
}).Create(&c).Error
|
||||
}
|
||||
|
||||
// ingestState maps Nezha State into v1.Report then saves a Record
|
||||
// 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()
|
||||
@@ -337,29 +335,26 @@ func ingestState(uuid string, st *proto.State) error {
|
||||
auditlog.EventLog("info", "auto created client "+client.Name)
|
||||
_ = db.Create(&client).Error
|
||||
}
|
||||
rep := v1.Report{
|
||||
CPU: v1.CPUReport{Usage: st.Cpu},
|
||||
Ram: v1.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
|
||||
Swap: v1.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
|
||||
Load: v1.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
|
||||
Disk: v1.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
|
||||
Network: v1.NetworkReport{
|
||||
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),
|
||||
}
|
||||
// 写入内存缓存,入库交由定时聚合任务处理
|
||||
savedReport, err := apiClient.SaveClientReport(uuid, rep)
|
||||
if err != nil {
|
||||
return err
|
||||
Uptime: int64(st.Uptime),
|
||||
Process: int(st.ProcessCount),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
// 更新实时缓存供前端使用
|
||||
agent_runtime.SetLatestReport(uuid, &savedReport)
|
||||
return nil
|
||||
ws.SetLatestReport(uuid, &rep)
|
||||
// 写入内存缓存,入库交由定时聚合任务处理
|
||||
return apiClient.SaveClientReport(uuid, rep)
|
||||
}
|
||||
|
||||
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
|
||||
@@ -375,7 +370,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
|
||||
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 cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
|
||||
if ip := net.ParseIP(v4); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
@@ -386,7 +381,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
|
||||
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
|
||||
updates["ipv6"] = v6
|
||||
if iso == "" { // 优先使用 v4 的国家码
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
|
||||
if ip := net.ParseIP(v6); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
@@ -1,11 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/internal/app"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
@@ -13,8 +16,15 @@ var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
a := app.New().With(dbcore.NewDBModule())
|
||||
err := a.RunWith(context.Background(), func(ctx context.Context) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&conf.V1Struct{}).Where("id = ?", 1).
|
||||
Update("disable_password_login", false).Error
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+52
-22
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -19,13 +21,7 @@ func GetEnv(key, defaultValue string) string {
|
||||
|
||||
// 从环境变量获取默认值
|
||||
var (
|
||||
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
|
||||
configFileEnv = GetEnv("KOMARI_CONFIG_FILE", "./data/komari.json")
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
@@ -33,29 +29,63 @@ var RootCmd = &cobra.Command{
|
||||
Short: "Komari is a simple server monitoring tool",
|
||||
Long: `Komari is a simple server monitoring tool.
|
||||
Made by Akizon77 with love.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.SetArgs([]string{"server"})
|
||||
cmd.Execute()
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
detectDeprecatedFlags()
|
||||
// Default to server when no subcommand is provided.
|
||||
if len(os.Args) <= 1 {
|
||||
RootCmd.SetArgs([]string{"server"})
|
||||
}
|
||||
// 创建目录
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
slog.Error("Failed to create theme directory", slog.Any("error", err))
|
||||
}
|
||||
if err := RootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [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, "Reserved database host parameter [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
|
||||
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
|
||||
_ = RootCmd.PersistentFlags().MarkHidden(name)
|
||||
// detectDeprecatedFlags 检测过时的数据库相关命令行参数
|
||||
func detectDeprecatedFlags() {
|
||||
deprecatedFlags := map[string]bool{
|
||||
"db-host": false,
|
||||
"database": false,
|
||||
"db-user": false,
|
||||
"db-pass": false,
|
||||
"db-port": false,
|
||||
"db-name": false,
|
||||
"db-type": false,
|
||||
}
|
||||
|
||||
// 检查命令行参数
|
||||
for _, arg := range os.Args[1:] {
|
||||
for flagName := range deprecatedFlags {
|
||||
if arg == "--"+flagName || arg == "-"+string(flagName[0]) {
|
||||
deprecatedFlags[flagName] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果检测到过时参数,记录警告
|
||||
hasDeprecated := false
|
||||
for flagName, found := range deprecatedFlags {
|
||||
if found {
|
||||
hasDeprecated = true
|
||||
slog.Warn("Deprecated command-line flag detected", slog.String("flag", "--"+flagName))
|
||||
}
|
||||
}
|
||||
|
||||
if hasDeprecated {
|
||||
slog.Warn("Command-line database configuration flags are deprecated. " +
|
||||
"Please migrate to komari.json file. ")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.ConfigFile, "config", "c", configFileEnv, "Configuration file path [env: KOMARI_CONFIG_FILE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", GetEnv("KOMARI_LISTEN", ":8080"), "Listen address [env: KOMARI_LISTEN]")
|
||||
}
|
||||
+11
-216
@@ -2,39 +2,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
|
||||
"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/pkg/config"
|
||||
"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/web/nezha"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
report_cache "github.com/komari-monitor/komari/web/report"
|
||||
"github.com/komari-monitor/komari/web/router"
|
||||
"github.com/komari-monitor/komari/web/security"
|
||||
"github.com/komari-monitor/komari/internal/app"
|
||||
"github.com/komari-monitor/komari/internal/scheduler"
|
||||
"github.com/komari-monitor/komari/internal/server"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -43,195 +16,17 @@ var ServerCmd = &cobra.Command{
|
||||
Short: "Start the server",
|
||||
Long: `Start the server`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunServer()
|
||||
a := app.New().
|
||||
WithShutdownTimeout(5 * time.Second).
|
||||
With(server.NewHTTPModule()).
|
||||
With(scheduler.NewModule())
|
||||
if err := a.RunUntilSignal(context.Background()); err != nil {
|
||||
cmd.PrintErrln(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 从环境变量获取监听地址
|
||||
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
|
||||
func RunServer() {
|
||||
// #region 初始化
|
||||
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
|
||||
log.Fatalf("Failed to create theme directory: %v", err)
|
||||
}
|
||||
InitDatabase()
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
// oidcInit
|
||||
go oauth.Initialize()
|
||||
|
||||
if conf.NezhaCompatEnabled {
|
||||
go func() {
|
||||
if err := nezha.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 := nezha.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 := nezha.StopNezhaCompat(); err != nil {
|
||||
log.Printf("stop Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
// 初始化 cloudflared
|
||||
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
|
||||
log.Printf("failed to auto start cloudflared: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
|
||||
})
|
||||
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
|
||||
|
||||
r.Use(api.IdentityMiddleware())
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.Register(r)
|
||||
|
||||
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() {
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
|
||||
}
|
||||
}
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
if err := tasks.ReloadPingSchedule(); err != nil {
|
||||
log.Println("Failed to reload ping schedule:", err)
|
||||
}
|
||||
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
|
||||
log.Println("Failed to reload load notification schedule:", err)
|
||||
}
|
||||
records.CompactRecord()
|
||||
|
||||
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
|
||||
log.Println("Failed to add cleanup scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
|
||||
log.Println("Failed to add minute scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
|
||||
log.Println("Failed to add expire notification scheduled task:", err)
|
||||
}
|
||||
notifier.InitTrafficReportSchedule()
|
||||
}
|
||||
|
||||
func cleanupScheduledData() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
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()
|
||||
accounts.RemoveExpiredSessions()
|
||||
}
|
||||
|
||||
func minuteScheduledWork() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
report_cache.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
notifier.CheckTraffic()
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
corn.StopAll()
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
if !ok || clientUUID == "" {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(update) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verify(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
token = utils.GenerateToken()
|
||||
clientUUID = uuid.New().String()
|
||||
|
||||
client := models.Client{
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
log.Println("Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
if name == "" {
|
||||
return CreateClient()
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
token = utils.GenerateToken()
|
||||
clientUUID = uuid.New().String()
|
||||
client := models.Client{
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
log.Println("Failed to apply default-on ping tasks to new client:", err)
|
||||
}
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
/*
|
||||
// GetAllClients 获取所有客户端配置
|
||||
|
||||
func getAllClients() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
*/
|
||||
func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
|
||||
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
|
||||
}
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.Token, nil
|
||||
}
|
||||
|
||||
func GetAllClientBasicInfo() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func SaveClient(updates map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := updates["uuid"].(string)
|
||||
if !ok || clientUUID == "" {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(updates) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var reportSaveLocks sync.Map
|
||||
|
||||
func getReportSaveLock(clientUUID string) *sync.Mutex {
|
||||
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
|
||||
var record models.Record
|
||||
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
err = db.Where("token = ?", token).First(&client).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
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")
|
||||
}
|
||||
|
||||
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
|
||||
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
|
||||
}
|
||||
|
||||
checkFloat64 := func(name string, val float64) error {
|
||||
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
|
||||
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [float64] 防止数据溢出
|
||||
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkInt64 := func(name string, val int64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, val)
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("Process must be non-negative: %d", report.Process)
|
||||
}
|
||||
if report.Connections.TCP < 0 {
|
||||
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
|
||||
}
|
||||
if report.Connections.UDP < 0 {
|
||||
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := ReportVerify(report); err != nil {
|
||||
return fmt.Errorf("failed to save Record: %w", 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: %w", 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,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
//Uptime: report.Uptime,
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
previous, err := getLatestTrafficRecord(tx, clientUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load previous Record: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
Record.TrafficUp = utils.ComputeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
|
||||
Record.TrafficDown = utils.ComputeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
|
||||
}
|
||||
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %w", 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,83 +0,0 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
|
||||
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
|
||||
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.want, utils.ComputeTrafficDelta(test.current, test.previous))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportSaveLockSerializesSameClient(t *testing.T) {
|
||||
clientUUID := "client-lock-same"
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
secondLock := getReportSaveLock(clientUUID)
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("same-client report save lock was acquired while already held")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
lock.Unlock()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("same-client report save lock was not released")
|
||||
}
|
||||
lock.Lock()
|
||||
}
|
||||
|
||||
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
|
||||
firstLock := getReportSaveLock("client-lock-a")
|
||||
firstLock.Lock()
|
||||
defer firstLock.Unlock()
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
waitGroup.Add(1)
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
secondLock := getReportSaveLock("client-lock-b")
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("different-client report save lock should not block")
|
||||
}
|
||||
waitGroup.Wait()
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/migrations"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
|
||||
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
|
||||
// 规范化排除路径为绝对路径
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
|
||||
out, err := os.Create(dstZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDir)
|
||||
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 排除 backup.zip 本身
|
||||
if _, ok := normExclude[path]; ok {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 计算 zip 内相对路径
|
||||
rel, err := filepath.Rel(absSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 根目录跳过
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 替换为正斜杠
|
||||
zipName := filepath.ToSlash(rel)
|
||||
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(zipName + "/")
|
||||
return err
|
||||
}
|
||||
// 普通文件
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(zipName)
|
||||
if err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, fh); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
fh.Close()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(absDir, e.Name())
|
||||
if _, ok := normExclude[full]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
|
||||
func unzipToDir(zipPath, dstDir string) error {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
absDst, _ := filepath.Abs(dstDir)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// 构造目标路径并做路径遍历保护
|
||||
cleanName := filepath.Clean(f.Name)
|
||||
targetPath := filepath.Join(absDst, cleanName)
|
||||
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
|
||||
return fmt.Errorf("illegal file path in zip: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func buildSQLiteDSN(databaseFile string) string {
|
||||
if databaseFile == "" {
|
||||
databaseFile = "./data/komari.db"
|
||||
}
|
||||
|
||||
params := "_busy_timeout=5000&_txlock=immediate&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
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 GetDBInstance() *gorm.DB {
|
||||
once.Do(func() {
|
||||
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logutil.NewGormLogger(),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// SQLite 连接
|
||||
// 通过 DSN 传入 _busy_timeout / _txlock 等参数,确保连接池中的每一条连接
|
||||
// 都生效:
|
||||
// - _busy_timeout=5000:遇到写锁时最多等待 5s 再返回,避免瞬时
|
||||
// "database is locked" 直接失败(仅靠后续 PRAGMA Exec 只作用于
|
||||
// 当时执行该语句的单条连接,池内其它连接不生效)。
|
||||
// - _txlock=immediate:事务一开始即获取写锁,避免「先 SELECT 后写」
|
||||
// 的锁升级在并发写入下产生死锁式的立即 SQLITE_BUSY。
|
||||
// - _journal_mode=WAL / _synchronous=NORMAL:与下方 PRAGMA 保持一致,
|
||||
// 在 DSN 层为所有连接预设。
|
||||
dsn := buildSQLiteDSN(flags.DatabaseFile)
|
||||
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
if sqlDB, dbErr := instance.DB(); dbErr == nil {
|
||||
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
|
||||
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
|
||||
// ping 结果等短写入撞锁并导致整批负载记录回滚。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(0)
|
||||
} else {
|
||||
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
|
||||
}
|
||||
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("PRAGMA synchronous = NORMAL;")
|
||||
instance.Exec("PRAGMA busy_timeout = 5000;")
|
||||
instance.Exec("PRAGMA cache_size = -65536;")
|
||||
instance.Exec("PRAGMA temp_store = MEMORY;")
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
log.Fatalf("Failed to run startup migrations: %v", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.GPURecord{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&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)
|
||||
}
|
||||
|
||||
// Manually create composite indexes
|
||||
if flags.IsSQLite() {
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_lt_client_time ON gpu_records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_ping_record_client_time ON ping_records(client, time)")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -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,30 +0,0 @@
|
||||
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,35 +0,0 @@
|
||||
package models
|
||||
|
||||
type PingRecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time LocalTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
// PingTask 表示一次延迟监测任务配置。
|
||||
type PingTask struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
|
||||
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
|
||||
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
|
||||
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
|
||||
}
|
||||
|
||||
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
|
||||
func (task PingTask) AppliesToClient(uuid string) bool {
|
||||
if uuid == "" {
|
||||
return false
|
||||
}
|
||||
for _, client := range task.Clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeConfigurationManaged = "managed"
|
||||
ThemeConfigurationRaw = "raw"
|
||||
ThemeConfigurationRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name any `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help any `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
func (t Theme) ConfigurationType() string {
|
||||
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
|
||||
if typ == "" {
|
||||
return ThemeConfigurationManaged
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func (t Theme) RawHTML() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRaw {
|
||||
return "", false
|
||||
}
|
||||
return configurationDataString(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) RedirectTarget() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRedirect {
|
||||
return "", false
|
||||
}
|
||||
return NormalizeThemeRedirectTarget(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) ValidateConfiguration() error {
|
||||
switch t.ConfigurationType() {
|
||||
case ThemeConfigurationManaged:
|
||||
return nil
|
||||
case ThemeConfigurationRaw:
|
||||
html, ok := t.RawHTML()
|
||||
if !ok || strings.TrimSpace(html) == "" {
|
||||
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
|
||||
}
|
||||
return nil
|
||||
case ThemeConfigurationRedirect:
|
||||
if _, ok := t.RedirectTarget(); !ok {
|
||||
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeThemeRedirectTarget(data any) (string, bool) {
|
||||
target, ok := configurationDataString(data)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cleanInputPath := parsed.Path
|
||||
if strings.HasPrefix(cleanInputPath, "/") {
|
||||
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
|
||||
} else {
|
||||
for strings.HasPrefix(cleanInputPath, "../") {
|
||||
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
|
||||
}
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(cleanInputPath, "/") {
|
||||
if segment == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath := cleanInputPath
|
||||
if cleanPath == "" {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
if cleanPath == "." {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := url.URL{
|
||||
Path: cleanPath,
|
||||
RawQuery: parsed.RawQuery,
|
||||
Fragment: parsed.Fragment,
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func configurationDataString(data any) (string, bool) {
|
||||
value, ok := data.(string)
|
||||
return value, ok
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
|
||||
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
|
||||
if daily {
|
||||
query = query.Where("daily = ?", true)
|
||||
} else if weekly {
|
||||
query = query.Where("weekly = ?", true)
|
||||
} else if monthly {
|
||||
query = query.Where("monthly = ?", true)
|
||||
}
|
||||
err := query.Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
|
||||
threshold := compactRecordCutoff(now)
|
||||
overlapCutoff := threshold.Add(-1 * 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{})
|
||||
expectedRawRemain := 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(15 * time.Minute)
|
||||
expectedGroups[slot] = struct{}{}
|
||||
}
|
||||
if !recTime.Before(overlapCutoff) {
|
||||
expectedRawRemain++
|
||||
}
|
||||
}
|
||||
|
||||
// 导出原始数据到 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 = migrateOldRecordsAt(db, now)
|
||||
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(expectedRawRemain), remainCount)
|
||||
|
||||
// 导出压缩后的数据到 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
currentTime := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
|
||||
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
|
||||
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{}))
|
||||
|
||||
records := []models.Record{
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now),
|
||||
NetTotalUp: 100,
|
||||
NetTotalDown: 200,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(5 * time.Minute)),
|
||||
NetTotalUp: 150,
|
||||
NetTotalDown: 260,
|
||||
TrafficUp: 50,
|
||||
TrafficDown: 60,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(10 * time.Minute)),
|
||||
NetTotalUp: 10,
|
||||
NetTotalDown: 30,
|
||||
TrafficUp: 10,
|
||||
TrafficDown: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
|
||||
require.Len(t, compacted, 1)
|
||||
assert.Equal(t, int64(60), compacted[0].TrafficUp)
|
||||
assert.Equal(t, int64(90), compacted[0].TrafficDown)
|
||||
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
|
||||
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasPreservesRawResetDetailBeforeCompaction(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
start := time.Date(2026, 6, 6, 0, 0, 0, 0, loc)
|
||||
records := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(start), NetTotalUp: 100, NetTotalDown: 200},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 140, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 20},
|
||||
{Client: uuid, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 35},
|
||||
}
|
||||
|
||||
repairZeroTrafficDeltas(records, nil)
|
||||
|
||||
assert.Equal(t, int64(0), records[0].TrafficUp)
|
||||
assert.Equal(t, int64(0), records[0].TrafficDown)
|
||||
assert.Equal(t, int64(40), records[1].TrafficUp)
|
||||
assert.Equal(t, int64(60), records[1].TrafficDown)
|
||||
assert.Equal(t, int64(10), records[2].TrafficUp)
|
||||
assert.Equal(t, int64(20), records[2].TrafficDown)
|
||||
assert.Equal(t, int64(15), records[3].TrafficUp)
|
||||
assert.Equal(t, int64(15), records[3].TrafficDown)
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasUsesPreviousPersistedBaseline(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 0, 0, 0, loc)
|
||||
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{}))
|
||||
|
||||
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(-10 * time.Minute)),
|
||||
NetTotalUp: 100,
|
||||
NetTotalDown: 200,
|
||||
}).Error)
|
||||
|
||||
rawRecords := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
|
||||
}
|
||||
|
||||
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
|
||||
assert.NoError(t, err)
|
||||
repairZeroTrafficDeltas(rawRecords, previousByClient)
|
||||
|
||||
assert.Equal(t, int64(50), rawRecords[0].TrafficUp)
|
||||
assert.Equal(t, int64(60), rawRecords[0].TrafficDown)
|
||||
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
|
||||
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
|
||||
}
|
||||
|
||||
func TestRepairZeroTrafficDeltasIgnoresSameSlotLongTermBaseline(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 0, 30, 0, loc)
|
||||
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{}))
|
||||
|
||||
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Truncate(15 * time.Minute)),
|
||||
NetTotalUp: 900,
|
||||
NetTotalDown: 900,
|
||||
}).Error)
|
||||
|
||||
rawRecords := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
|
||||
}
|
||||
|
||||
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
|
||||
assert.NoError(t, err)
|
||||
repairZeroTrafficDeltas(rawRecords, previousByClient)
|
||||
|
||||
assert.Equal(t, int64(0), rawRecords[0].TrafficUp)
|
||||
assert.Equal(t, int64(0), rawRecords[0].TrafficDown)
|
||||
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
|
||||
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
|
||||
}
|
||||
|
||||
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
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{}))
|
||||
|
||||
records := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
|
||||
}
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var remainTimes []models.Record
|
||||
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
|
||||
require.Len(t, remainTimes, 2)
|
||||
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
|
||||
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
|
||||
}
|
||||
|
||||
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
|
||||
loc := models.GetAppLocation()
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
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{}))
|
||||
|
||||
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
|
||||
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
|
||||
assert.NoError(t, db.Create(&compactable).Error)
|
||||
assert.NoError(t, db.Create(&partialSlot).Error)
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
|
||||
require.Len(t, compacted, 1)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
|
||||
|
||||
var rawCount int64
|
||||
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
|
||||
assert.Equal(t, int64(1), rawCount)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
|
||||
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
normalizedClients := normalizePingClients(models.StringArray(clients))
|
||||
task := models.PingTask{
|
||||
Clients: normalizedClients,
|
||||
DefaultOn: defaultOn,
|
||||
Name: name,
|
||||
Type: task_type,
|
||||
Target: target,
|
||||
Interval: interval,
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append by id to avoid races between concurrent create requests.
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return task.Id, nil
|
||||
}
|
||||
|
||||
func DeletePingTask(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// EditPingTask 批量更新延迟监测任务配置。
|
||||
func EditPingTask(tasks []*models.PingTask) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, task := range tasks {
|
||||
task.Clients = normalizePingClients(task.Clients)
|
||||
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
|
||||
updates := map[string]interface{}{
|
||||
"name": task.Name,
|
||||
"clients": task.Clients,
|
||||
"all_clients": task.DefaultOn,
|
||||
"type": task.Type,
|
||||
"target": task.Target,
|
||||
"interval": task.Interval,
|
||||
}
|
||||
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
|
||||
func normalizePingClients(clients models.StringArray) models.StringArray {
|
||||
if clients == nil {
|
||||
return models.StringArray{}
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func GetAllPingTasks() ([]models.PingTask, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
|
||||
func GetPingTasksByClient(uuid string) []models.PingTask {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func UpdatePingTaskOrder(order map[uint]int) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for id, weight := range order {
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ReloadPingSchedule()
|
||||
return nil
|
||||
}
|
||||
|
||||
func SavePingRecord(record models.PingRecord) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
}
|
||||
|
||||
func DeletePingRecordsBefore(time time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func DeleteAllPingRecords() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Exec("DELETE FROM ping_records")
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
func ReloadPingSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var pingTasks []models.PingTask
|
||||
if err := db.Find(&pingTasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return utils.ReloadPingSchedule(pingTasks)
|
||||
}
|
||||
|
||||
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
|
||||
func AddDefaultOnClientUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for _, task := range tasks {
|
||||
exists := false
|
||||
for _, c := range task.Clients {
|
||||
if c == uuid {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
next := append(models.StringArray{}, task.Clients...)
|
||||
next = append(next, uuid)
|
||||
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return ReloadPingSchedule()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.PingRecord
|
||||
dbQuery := db.Model(&models.PingRecord{})
|
||||
if uuid != "" {
|
||||
dbQuery = dbQuery.Where("client = ?", uuid)
|
||||
}
|
||||
if taskId >= 0 {
|
||||
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
|
||||
}
|
||||
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
+15
-28
@@ -4,20 +4,15 @@
|
||||
|
||||

|
||||
|
||||
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
|
||||
Komari は、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Web インターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
|
||||
|
||||
> [!WARNING]
|
||||
> Komariは、自己ホスト型の監視/制御プログラムであり、所有しているシステム、または管理権限を得ているシステムでのみ使用してください。Komariを武器化したり、許可なく展開、アクセス、永続化、コマンド実行、その他の不正利用に使用したりしないでください。実際の悪用リスクについては、Huntressの分析をご参照ください:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> Komariの展開および運用方法については、利用者自身が責任を負います。開発者は、無許可または不正な利用、およびその結果について責任を負いません。
|
||||
> Windows端末でリモートコントロールを有効にした場合、クライアントはユーザーがログインするたびに、KomariがリモートコントロールソフトウェアであることをWindows通知で知らせます。
|
||||
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegram グループ](https://t.me/komari_monitor)
|
||||
|
||||
## 特徴
|
||||
|
||||
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
|
||||
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
|
||||
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
- **Web インターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
@@ -27,13 +22,9 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel アプリストア
|
||||
|
||||
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
|
||||
|
||||
### 1. ワンクリックインストールスクリプトを使用する
|
||||
|
||||
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
|
||||
systemd を使用するディストリビューション(Ubuntu、Debian など)に適しています。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
@@ -41,13 +32,13 @@ chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker展開
|
||||
### 2. Docker 展開
|
||||
|
||||
1. データディレクトリを作成します:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. Dockerコンテナを実行します:
|
||||
2. Docker コンテナを実行します:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
@@ -66,8 +57,8 @@ sudo ./install-komari.sh
|
||||
|
||||
### 3. バイナリファイル展開
|
||||
|
||||
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komariを実行します:
|
||||
1. Komari の[GitHub リリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komari を実行します:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
@@ -81,7 +72,7 @@ sudo ./install-komari.sh
|
||||
|
||||
#### 依存関係
|
||||
|
||||
- Go 1.18+ および Node.js 20+(手動ビルド用)
|
||||
- Go 1.24+ および Node.js 20+(手動ビルド用)
|
||||
|
||||
1. フロントエンドの静的ファイルをビルドします:
|
||||
```bash
|
||||
@@ -95,7 +86,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
|
||||
ステップ 1 で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
@@ -107,24 +98,20 @@ sudo ./install-komari.sh
|
||||
|
||||
## フロントエンド開発ガイド
|
||||
|
||||
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
[Komari テーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
[Crowdin で Komari を翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## クライアントエージェント開発ガイド
|
||||
|
||||
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
|
||||
[Komari エージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢献
|
||||
|
||||
IssueやPull Requestを歓迎します!
|
||||
Issue や Pull Request を歓迎します!
|
||||
|
||||
## 謝辞
|
||||
|
||||
### 破碎工坊云 (Crash Work)
|
||||
|
||||
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
|
||||
@@ -143,6 +130,6 @@ PR を送ってくれた方、テーマを作成してくれた全ての開発
|
||||
|
||||
—— そして:こんなに暇でいられる自分に感謝
|
||||
|
||||
## Star履歴
|
||||
## Star 履歴
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
+2
-15
@@ -6,11 +6,6 @@
|
||||
|
||||
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自託管的監控/控制程式,僅應部署在你擁有或已獲授權管理的系統上。請勿將 Komari 武器化,或在未獲授權的情況下部署、存取、持久化、執行命令及從事其他濫用行為。關於現實中的濫用風險,可參考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> 使用者需要自行承擔部署和使用 Komari 的責任。開發者不對未經授權或濫用行為及其後果承擔責任。
|
||||
> 在 Windows 端啟用遠端控制後,客戶端會在每次使用者登入時透過 Windows 通知提醒使用者 Komari 是一款遠端控制軟體。
|
||||
|
||||
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
@@ -27,10 +22,6 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel 應用商店
|
||||
|
||||
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
|
||||
|
||||
### 1. 使用一鍵安裝腳本
|
||||
|
||||
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
|
||||
@@ -81,7 +72,7 @@ sudo ./install-komari.sh
|
||||
|
||||
#### 依賴
|
||||
|
||||
- Go 1.18+ 和 Node.js 20+(手工建置)
|
||||
- Go 1.24+ 和 Node.js 20+(手工建置)
|
||||
|
||||
1. 建置前端靜態檔案:
|
||||
```bash
|
||||
@@ -95,7 +86,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
|
||||
將步驟 1 中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
@@ -121,10 +112,6 @@ sudo ./install-komari.sh
|
||||
|
||||
## 鳴謝
|
||||
|
||||
### 破碎工坊雲
|
||||
|
||||
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
|
||||
|
||||
+2
-15
@@ -6,11 +6,6 @@
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
|
||||
|
||||
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
@@ -27,10 +22,6 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel 应用商店
|
||||
|
||||
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
|
||||
|
||||
### 1. 使用一键安装脚本
|
||||
|
||||
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
|
||||
@@ -81,7 +72,7 @@ sudo ./install-komari.sh
|
||||
|
||||
#### 依赖
|
||||
|
||||
- Go 1.18+ 和 Node.js 20+(手工构建)
|
||||
- Go 1.24+ 和 Node.js 20+(手工构建)
|
||||
|
||||
1. 构建前端静态文件:
|
||||
```bash
|
||||
@@ -95,7 +86,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
|
||||
将步骤 1 中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
@@ -121,10 +112,6 @@ sudo ./install-komari.sh
|
||||
|
||||
## 鸣谢
|
||||
|
||||
### 破碎工坊云
|
||||
|
||||
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
module github.com/komari-monitor/komari
|
||||
|
||||
go 1.25.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gookit/event v1.2.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/image v0.35.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/image v0.33.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
)
|
||||
@@ -33,7 +34,7 @@ require golang.org/x/crypto v0.39.0 // indirect
|
||||
require golang.org/x/net v0.41.0 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
@@ -47,12 +48,11 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/gookit/goutil v0.7.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -60,6 +60,7 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.27 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
@@ -70,9 +71,9 @@ require (
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
@@ -47,8 +47,9 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -60,18 +61,14 @@ github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQu
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gookit/event v1.2.0 h1:aa8ouNVlo4E/NRhHVXNU/JbWvlr91Gjh423WtCDYQ4Q=
|
||||
github.com/gookit/event v1.2.0/go.mod h1:gGYybJL0HEEo/+UmBN+MgLqUBIxcCGOP8FrLPk+J8w4=
|
||||
github.com/gookit/goutil v0.7.1 h1:AaFJPN9mrdeYBv8HOybri26EHGCC34WJVT7jUStGJsI=
|
||||
github.com/gookit/goutil v0.7.1/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -83,6 +80,7 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -128,9 +126,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -151,19 +148,19 @@ golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
@@ -182,8 +179,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
|
||||
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
+77
-297
@@ -31,135 +31,9 @@ SERVICE_NAME="komari"
|
||||
BINARY_PATH="$INSTALL_DIR/komari"
|
||||
DEFAULT_PORT="25774"
|
||||
LISTEN_PORT=""
|
||||
REPO="komari-monitor/komari"
|
||||
# 发布通道: stable(稳定版)或 snapshot(快照版)
|
||||
CHANNEL="stable"
|
||||
# TUI 工具: whiptail / dialog / 空(回退纯文本)
|
||||
TUI_TOOL=""
|
||||
|
||||
# ==========================================================
|
||||
# TUI / 交互层
|
||||
# ==========================================================
|
||||
|
||||
# 检测可用的 TUI 工具
|
||||
detect_tui() {
|
||||
if command -v whiptail >/dev/null 2>&1; then
|
||||
TUI_TOOL="whiptail"
|
||||
elif command -v dialog >/dev/null 2>&1; then
|
||||
TUI_TOOL="dialog"
|
||||
else
|
||||
TUI_TOOL=""
|
||||
fi
|
||||
}
|
||||
|
||||
# 是否启用 TUI
|
||||
tui_enabled() {
|
||||
[ -n "$TUI_TOOL" ]
|
||||
}
|
||||
|
||||
# 菜单选择
|
||||
# 用法: ui_menu "标题" "提示" tag1 "item1" tag2 "item2" ...
|
||||
# 返回: 选中的 tag(输出到 stdout),取消返回非零
|
||||
ui_menu() {
|
||||
local title="$1"; shift
|
||||
local prompt="$1"; shift
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --menu "$prompt" 20 70 10 "$@" 3>&1 1>&2 2>&3
|
||||
return $?
|
||||
fi
|
||||
|
||||
# 纯文本回退
|
||||
{
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $title"
|
||||
echo "=============================================================="
|
||||
echo "$prompt"
|
||||
echo
|
||||
local tag item
|
||||
local args=("$@")
|
||||
local i=0
|
||||
while [ $i -lt ${#args[@]} ]; do
|
||||
tag="${args[$i]}"
|
||||
item="${args[$((i + 1))]}"
|
||||
echo " $tag) $item"
|
||||
i=$((i + 2))
|
||||
done
|
||||
echo
|
||||
} >&2
|
||||
local choice
|
||||
read -r -p "输入选项: " choice >&2
|
||||
echo "$choice"
|
||||
}
|
||||
|
||||
# 输入框
|
||||
# 用法: ui_input "标题" "提示" "默认值"
|
||||
# 返回: 输入内容(输出到 stdout),取消返回非零
|
||||
ui_input() {
|
||||
local title="$1"
|
||||
local prompt="$2"
|
||||
local default="$3"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --inputbox "$prompt" 12 70 "$default" 3>&1 1>&2 2>&3
|
||||
return $?
|
||||
fi
|
||||
|
||||
local input
|
||||
read -r -p "$prompt [默认: $default]: " input >&2
|
||||
if [ -z "$input" ]; then
|
||||
echo "$default"
|
||||
else
|
||||
echo "$input"
|
||||
fi
|
||||
}
|
||||
|
||||
# 是/否确认
|
||||
# 用法: ui_yesno "标题" "提示"
|
||||
# 返回: 0 表示 是,1 表示 否
|
||||
ui_yesno() {
|
||||
local title="$1"
|
||||
local prompt="$2"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --yesno "$prompt" 12 70
|
||||
return $?
|
||||
fi
|
||||
|
||||
local confirm
|
||||
read -r -p "$prompt (Y/n): " confirm >&2
|
||||
if [[ $confirm =~ ^[Nn]$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 信息提示框
|
||||
# 用法: ui_msgbox "标题" "内容"
|
||||
ui_msgbox() {
|
||||
local title="$1"
|
||||
local content="$2"
|
||||
|
||||
if tui_enabled; then
|
||||
$TUI_TOOL --title "$title" --msgbox "$content" 20 72
|
||||
return
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $title"
|
||||
echo "=============================================================="
|
||||
echo -e "$content"
|
||||
echo "=============================================================="
|
||||
read -r -p "按回车键继续..." _
|
||||
}
|
||||
|
||||
# 显示横幅(仅纯文本模式)
|
||||
# Show banner
|
||||
show_banner() {
|
||||
if tui_enabled; then
|
||||
return
|
||||
fi
|
||||
clear
|
||||
echo "=============================================================="
|
||||
echo " Komari Monitoring System Installer"
|
||||
@@ -168,31 +42,6 @@ show_banner() {
|
||||
echo
|
||||
}
|
||||
|
||||
# 选择发布通道,结果写入全局变量 CHANNEL
|
||||
select_channel() {
|
||||
local choice
|
||||
choice=$(ui_menu "选择发布通道" "请选择要使用的发布通道:" \
|
||||
"stable" "稳定版 (推荐)" \
|
||||
"snapshot" "快照版 (最新功能)")
|
||||
|
||||
case "$choice" in
|
||||
snapshot|2)
|
||||
CHANNEL="snapshot"
|
||||
;;
|
||||
stable|1|"")
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
*)
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
esac
|
||||
log_info "已选择通道: $CHANNEL"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# 基础检查
|
||||
# ==========================================================
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
@@ -264,54 +113,19 @@ install_dependencies() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Get download URL based on channel
|
||||
get_download_url() {
|
||||
local arch=$1
|
||||
local file_name="komari-linux-${arch}"
|
||||
|
||||
if [ "$CHANNEL" = "snapshot" ]; then
|
||||
# 获取最新的 snapshot 预发布版本
|
||||
log_info "获取最新 snapshot 版本..." >&2
|
||||
local latest_snapshot=$(curl -s "https://api.github.com/repos/${REPO}/releases" | grep '"tag_name"' | grep 'Snapshot-' | head -1 | sed -e 's/.*"tag_name": *"//' -e 's/".*//')
|
||||
|
||||
if [ -z "$latest_snapshot" ]; then
|
||||
log_error "未找到 snapshot 版本" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "最新 snapshot 版本: $latest_snapshot" >&2
|
||||
echo "https://github.com/${REPO}/releases/download/${latest_snapshot}/${file_name}"
|
||||
else
|
||||
# 稳定版:使用 latest
|
||||
echo "https://github.com/${REPO}/releases/latest/download/${file_name}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# 业务操作
|
||||
# ==========================================================
|
||||
|
||||
# Binary installation
|
||||
install_binary() {
|
||||
log_step "开始二进制安装..."
|
||||
|
||||
if is_installed; then
|
||||
ui_msgbox "提示" "Komari 已安装。\n如需升级,请使用主菜单中的升级选项。"
|
||||
log_info "Komari 已安装。要升级,请使用升级选项。"
|
||||
return
|
||||
fi
|
||||
|
||||
# 选择发布通道
|
||||
select_channel
|
||||
|
||||
# 监听端口输入,校验范围 1-65535
|
||||
while true; do
|
||||
local input_port
|
||||
input_port=$(ui_input "监听端口" "请输入 Komari 的监听端口 (1-65535):" "$DEFAULT_PORT")
|
||||
# 取消输入
|
||||
if [ $? -ne 0 ]; then
|
||||
log_info "安装已取消"
|
||||
return
|
||||
fi
|
||||
read -p "请输入监听端口 [默认: $DEFAULT_PORT]: " input_port
|
||||
if [[ -z "$input_port" ]]; then
|
||||
LISTEN_PORT="$DEFAULT_PORT"
|
||||
break
|
||||
@@ -319,7 +133,7 @@ install_binary() {
|
||||
LISTEN_PORT="$input_port"
|
||||
break
|
||||
else
|
||||
ui_msgbox "错误" "端口号无效,请输入 1-65535 之间的数字。"
|
||||
log_error "端口号无效,请输入 1-65535 之间的数字。"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -334,17 +148,14 @@ install_binary() {
|
||||
log_step "创建数据目录: $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
local download_url=$(get_download_url "$arch")
|
||||
if [ $? -ne 0 ]; then
|
||||
ui_msgbox "错误" "获取下载链接失败,请检查网络连接或稍后重试。"
|
||||
return 1
|
||||
fi
|
||||
local file_name="komari-linux-${arch}"
|
||||
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
|
||||
|
||||
log_step "下载 Komari 二进制文件..."
|
||||
log_info "URL: $download_url"
|
||||
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
ui_msgbox "错误" "下载失败,请检查网络连接。"
|
||||
log_error "下载失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -352,7 +163,11 @@ install_binary() {
|
||||
log_success "Komari 二进制文件安装完成: $BINARY_PATH"
|
||||
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "安装完成" "警告:未检测到 systemd,已跳过服务创建。\n\n您可以手动运行 Komari:\n $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
|
||||
log_step "警告:未检测到 systemd,跳过服务创建。"
|
||||
log_step "您可以从命令行手动运行 Komari:"
|
||||
log_step " $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -364,16 +179,17 @@ install_binary() {
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
log_success "Komari 服务启动成功"
|
||||
|
||||
|
||||
log_step "正在获取初始密码..."
|
||||
sleep 5
|
||||
sleep 5
|
||||
local password=$(journalctl -u ${SERVICE_NAME} --since "1 minute ago" | grep "admin account created." | tail -n 1 | sed -e 's/.*admin account created.//')
|
||||
if [ -z "$password" ]; then
|
||||
log_error "未能获取初始密码,请检查日志"
|
||||
fi
|
||||
show_access_info "$password" "$LISTEN_PORT"
|
||||
else
|
||||
ui_msgbox "错误" "Komari 服务启动失败。\n\n查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||
log_error "Komari 服务启动失败"
|
||||
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -407,22 +223,21 @@ EOF
|
||||
show_access_info() {
|
||||
local password=$1
|
||||
local port=${2:-$DEFAULT_PORT}
|
||||
local ip=$(hostname -I | awk '{print $1}')
|
||||
|
||||
local content="安装完成!\n\n"
|
||||
content+="访问信息:\n"
|
||||
content+=" URL: http://${ip}:${port}\n"
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
echo
|
||||
log_info "访问信息:"
|
||||
log_info " URL: http://$(hostname -I | awk '{print $1}'):${port}"
|
||||
if [ -n "$password" ]; then
|
||||
content+=" 初始登录信息(仅显示一次): ${password}\n"
|
||||
log_info "初始登录信息(仅显示一次): $password"
|
||||
fi
|
||||
content+="\n服务管理命令:\n"
|
||||
content+=" 状态: systemctl status $SERVICE_NAME\n"
|
||||
content+=" 启动: systemctl start $SERVICE_NAME\n"
|
||||
content+=" 停止: systemctl stop $SERVICE_NAME\n"
|
||||
content+=" 重启: systemctl restart $SERVICE_NAME\n"
|
||||
content+=" 日志: journalctl -u $SERVICE_NAME -f"
|
||||
|
||||
ui_msgbox "安装完成" "$content"
|
||||
echo
|
||||
log_info "服务管理命令:"
|
||||
log_info " 状态: systemctl status $SERVICE_NAME"
|
||||
log_info " 启动: systemctl start $SERVICE_NAME"
|
||||
log_info " 停止: systemctl stop $SERVICE_NAME"
|
||||
log_info " 重启: systemctl restart $SERVICE_NAME"
|
||||
log_info " 日志: journalctl -u $SERVICE_NAME -f"
|
||||
}
|
||||
|
||||
# Upgrade function
|
||||
@@ -430,18 +245,15 @@ upgrade_komari() {
|
||||
log_step "升级 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。请先安装它。"
|
||||
log_error "Komari 未安装。请先安装它。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法管理服务。"
|
||||
log_error "未检测到 systemd。无法管理服务。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 选择发布通道
|
||||
select_channel
|
||||
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
|
||||
@@ -449,21 +261,14 @@ upgrade_komari() {
|
||||
cp "$BINARY_PATH" "${BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
local arch=$(detect_arch)
|
||||
local download_url=$(get_download_url "$arch")
|
||||
if [ $? -ne 0 ]; then
|
||||
log_error "获取下载链接失败,正在从备份恢复"
|
||||
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
ui_msgbox "错误" "获取下载链接失败,已从备份恢复。"
|
||||
return 1
|
||||
fi
|
||||
local file_name="komari-linux-${arch}"
|
||||
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
|
||||
|
||||
log_step "下载最新版本..."
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
log_error "下载失败,正在从备份恢复"
|
||||
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
ui_msgbox "错误" "下载失败,已从备份恢复。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -473,9 +278,9 @@ upgrade_komari() {
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
ui_msgbox "升级完成" "Komari 升级成功 (通道: $CHANNEL)。"
|
||||
log_success "Komari 升级成功"
|
||||
else
|
||||
ui_msgbox "错误" "服务在升级后未能启动,请检查日志。"
|
||||
log_error "服务在升级后未能启动"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -484,11 +289,12 @@ uninstall_komari() {
|
||||
log_step "卸载 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
ui_msgbox "提示" "Komari 未安装。"
|
||||
log_info "Komari 未安装"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! ui_yesno "确认卸载" "这将删除 Komari 二进制文件和服务。\n\n您确定要继续吗?"; then
|
||||
read -p "这将删除 Komari。您确定吗?(Y/n): " confirm
|
||||
if [[ $confirm =~ ^[Nn]$ ]]; then
|
||||
log_info "卸载已取消"
|
||||
return 0
|
||||
fi
|
||||
@@ -508,128 +314,102 @@ uninstall_komari() {
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || log_info "数据目录 $INSTALL_DIR 不为空,未删除"
|
||||
log_success "Komari 二进制文件已删除"
|
||||
|
||||
ui_msgbox "卸载完成" "Komari 卸载完成。\n\n数据文件保留在 $DATA_DIR"
|
||||
log_success "Komari 卸载完成"
|
||||
log_info "数据文件保留在 $DATA_DIR"
|
||||
}
|
||||
|
||||
# Show service status
|
||||
show_status() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法获取服务状态。"
|
||||
log_error "未检测到 systemd。无法获取服务状态。"
|
||||
return
|
||||
fi
|
||||
if tui_enabled; then
|
||||
local status_output
|
||||
status_output=$(systemctl status ${SERVICE_NAME}.service --no-pager -l 2>&1)
|
||||
ui_msgbox "服务状态" "$status_output"
|
||||
else
|
||||
log_step "Komari 服务状态:"
|
||||
systemctl status ${SERVICE_NAME}.service --no-pager -l
|
||||
read -r -p "按回车键继续..." _
|
||||
fi
|
||||
log_step "Komari 服务状态:"
|
||||
systemctl status ${SERVICE_NAME}.service --no-pager -l
|
||||
}
|
||||
|
||||
# Show service logs
|
||||
show_logs() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法获取服务日志。"
|
||||
log_error "未检测到 systemd。无法获取服务日志。"
|
||||
return
|
||||
fi
|
||||
# 日志为实时流,直接在终端显示
|
||||
if tui_enabled; then
|
||||
clear
|
||||
fi
|
||||
log_step "查看 Komari 服务日志 (按 Ctrl+C 退出)..."
|
||||
log_step "查看 Komari 服务日志..."
|
||||
journalctl -u ${SERVICE_NAME} -f --no-pager
|
||||
}
|
||||
|
||||
# Restart service
|
||||
restart_service() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法重启服务。"
|
||||
log_error "未检测到 systemd。无法重启服务。"
|
||||
return
|
||||
fi
|
||||
log_step "重启 Komari 服务..."
|
||||
systemctl restart ${SERVICE_NAME}.service
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
ui_msgbox "成功" "服务重启成功。"
|
||||
log_success "服务重启成功"
|
||||
else
|
||||
ui_msgbox "错误" "服务重启失败,请检查日志。"
|
||||
log_error "服务重启失败"
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop service
|
||||
stop_service() {
|
||||
if ! is_installed; then
|
||||
ui_msgbox "错误" "Komari 未安装。"
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
ui_msgbox "错误" "未检测到 systemd。无法停止服务。"
|
||||
log_error "未检测到 systemd。无法停止服务。"
|
||||
return
|
||||
fi
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
ui_msgbox "成功" "服务已停止。"
|
||||
log_success "服务已停止"
|
||||
}
|
||||
|
||||
|
||||
# Main menu
|
||||
main_menu() {
|
||||
while true; do
|
||||
show_banner
|
||||
show_banner
|
||||
echo "请选择操作:"
|
||||
echo " 1) 安装 Komari"
|
||||
echo " 2) 升级 Komari"
|
||||
echo " 3) 卸载 Komari"
|
||||
echo " 4) 查看状态"
|
||||
echo " 5) 查看日志"
|
||||
echo " 6) 重启服务"
|
||||
echo " 7) 停止服务"
|
||||
echo " 8) 退出"
|
||||
echo
|
||||
|
||||
local choice
|
||||
choice=$(ui_menu "Komari 监控系统安装器" "请选择操作:" \
|
||||
"1" "安装 Komari" \
|
||||
"2" "升级 Komari" \
|
||||
"3" "卸载 Komari" \
|
||||
"4" "查看状态" \
|
||||
"5" "查看日志" \
|
||||
"6" "重启服务" \
|
||||
"7" "停止服务" \
|
||||
"8" "退出")
|
||||
read -p "输入选项 [1-8]: " choice
|
||||
|
||||
# 用户在 TUI 中取消(ESC/Cancel)则退出
|
||||
if [ $? -ne 0 ] && tui_enabled; then
|
||||
clear
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case $choice in
|
||||
1) install_binary ;;
|
||||
2) upgrade_komari ;;
|
||||
3) uninstall_komari ;;
|
||||
4) show_status ;;
|
||||
5) show_logs ;;
|
||||
6) restart_service ;;
|
||||
7) stop_service ;;
|
||||
8)
|
||||
tui_enabled && clear
|
||||
exit 0
|
||||
;;
|
||||
*) ui_msgbox "错误" "无效选项" ;;
|
||||
esac
|
||||
|
||||
# 纯文本模式下单次执行后退出循环(保持原有行为,避免输出被覆盖)
|
||||
if ! tui_enabled; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
case $choice in
|
||||
1) install_binary ;;
|
||||
2) upgrade_komari ;;
|
||||
3) uninstall_komari ;;
|
||||
4) show_status ;;
|
||||
5) show_logs ;;
|
||||
6) restart_service ;;
|
||||
7) stop_service ;;
|
||||
8) exit 0 ;;
|
||||
*) log_error "无效选项" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main execution
|
||||
check_root
|
||||
detect_tui
|
||||
main_menu
|
||||
@@ -0,0 +1,29 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
// Import all internal packages to ensure their init() functions are executed
|
||||
_ "github.com/komari-monitor/komari/internal/api_rpc"
|
||||
_ "github.com/komari-monitor/komari/internal/api_v1"
|
||||
_ "github.com/komari-monitor/komari/internal/client"
|
||||
_ "github.com/komari-monitor/komari/internal/cloudflared"
|
||||
_ "github.com/komari-monitor/komari/internal/common"
|
||||
_ "github.com/komari-monitor/komari/internal/conf"
|
||||
_ "github.com/komari-monitor/komari/internal/database"
|
||||
_ "github.com/komari-monitor/komari/internal/eventType"
|
||||
_ "github.com/komari-monitor/komari/internal/geoip"
|
||||
_ "github.com/komari-monitor/komari/internal/jsruntime"
|
||||
_ "github.com/komari-monitor/komari/internal/log"
|
||||
_ "github.com/komari-monitor/komari/internal/messageSender"
|
||||
_ "github.com/komari-monitor/komari/internal/mjpeg"
|
||||
_ "github.com/komari-monitor/komari/internal/nezha"
|
||||
_ "github.com/komari-monitor/komari/internal/notifier"
|
||||
_ "github.com/komari-monitor/komari/internal/oauth"
|
||||
_ "github.com/komari-monitor/komari/internal/patch"
|
||||
_ "github.com/komari-monitor/komari/internal/pingSchedule"
|
||||
_ "github.com/komari-monitor/komari/internal/plugin"
|
||||
_ "github.com/komari-monitor/komari/internal/renewal"
|
||||
_ "github.com/komari-monitor/komari/internal/restore"
|
||||
_ "github.com/komari-monitor/komari/internal/ws"
|
||||
)
|
||||
|
||||
func All() {}
|
||||
@@ -0,0 +1,272 @@
|
||||
package api_rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
func RegisterRouters(path string, r *gin.Engine) {
|
||||
r.GET(path, OnRpcRequest)
|
||||
r.POST(path, OnRpcRequest)
|
||||
}
|
||||
|
||||
// Json Rpc2 over websocket, /api/rpc2
|
||||
func OnRpcRequest(c *gin.Context) {
|
||||
|
||||
// GET -> WebSocket
|
||||
if c.Request.Method == http.MethodGet {
|
||||
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
|
||||
if conf.Conf.Site.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"})
|
||||
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 GroupClient:
|
||||
if permissionGroup == GroupClient || permissionGroup == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
case "admin":
|
||||
if permissionGroup == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
default:
|
||||
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.Unauthenticated, "Unauthorized", nil))
|
||||
continue
|
||||
}
|
||||
if !allowed {
|
||||
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.PermissionDenied, "Permission denied", 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("token")
|
||||
if _, err := client.GetClientUUIDByToken(token); err == nil {
|
||||
permissionGroup = GroupClient
|
||||
}
|
||||
if session_token, _ := c.Cookie("session_token"); session_token != "" {
|
||||
if _, err := accounts.GetUserBySession(session_token); err == nil {
|
||||
permissionGroup = "admin"
|
||||
}
|
||||
}
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if apiKey == "Bearer "+conf.Conf.Login.ApiKey {
|
||||
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 token / Header Authorization Bearer token)
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
// 兼容 header Bearer <token>
|
||||
hAuth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(hAuth, "Bearer ") {
|
||||
token = strings.TrimPrefix(hAuth, "Bearer ")
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
if uuid, err := client.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.Background(), meta)
|
||||
|
||||
switch fc[0] {
|
||||
case "guest", "", "rpc", "common":
|
||||
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
|
||||
case GroupClient:
|
||||
if permissionGroup == GroupClient || permissionGroup == "admin" {
|
||||
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
|
||||
} else {
|
||||
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission Denied", 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, rpc.PermissionDenied, "Permission Denied", nil))
|
||||
}
|
||||
default:
|
||||
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.Unauthenticated, "Unauthenticated", nil))
|
||||
}
|
||||
}
|
||||
|
||||
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
|
||||
// group: 调用者的权限分组 (guest/client/admin)
|
||||
// method: RPC 方法名 (如 "common:getNodes")
|
||||
// params: 方法参数
|
||||
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
|
||||
// 检查私有站点
|
||||
if conf.Conf.Site.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 GroupClient:
|
||||
if group == GroupClient || 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)
|
||||
}
|
||||
|
||||
// registry holds method handlers keyed by "namespace:MethodName"
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = make(map[string]reflect.Value)
|
||||
)
|
||||
|
||||
// Register 将以分组common注册
|
||||
func Register(name string, cb rpc.Handler) error {
|
||||
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
|
||||
Name: name,
|
||||
Summary: "This method does not provide a summary",
|
||||
Description: "This method does not provide a description",
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterWithGroup 将回调按分组注册。
|
||||
// 传入函数: Register("ns", someFunc) -> 注册 ns:SomeFunc
|
||||
// group 为空时使用默认分组 "common"。
|
||||
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
|
||||
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
|
||||
}
|
||||
|
||||
// GetRegisteredKeys 返回已注册的方法 key 列表 (调试用)。
|
||||
func GetRegisteredKeys() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
keys := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package api_rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
const GroupClient = "client"
|
||||
|
||||
func init() {
|
||||
RegisterWithGroupAndMeta("register", GroupClient, registerClient, &rpc.MethodMeta{
|
||||
Name: "RegisterClient",
|
||||
Description: "Register a new client with the server (Auto discovery).",
|
||||
Params: []rpc.ParamMeta{
|
||||
{
|
||||
Name: "name",
|
||||
Type: "string",
|
||||
Description: "The name of the client to register.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func registerClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
var params struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Not impl yet", nil)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package jsonrpc
|
||||
package api_rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,17 +8,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/vars"
|
||||
"github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
"github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
report_cache "github.com/komari-monitor/komari/web/report"
|
||||
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
@@ -50,8 +49,11 @@ func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pi
|
||||
// 筛选属于该节点的任务
|
||||
assigned := make([]models.PingTask, 0, 4)
|
||||
for _, t := range pingTasks {
|
||||
if t.AppliesToClient(uuid) {
|
||||
assigned = append(assigned, t)
|
||||
for _, c := range t.Clients {
|
||||
if c == uuid {
|
||||
assigned = append(assigned, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(assigned) == 0 {
|
||||
@@ -180,7 +182,7 @@ func init() {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
Returns: "Client | Client[]",
|
||||
Returns: "Client | { [uuid]: Client }",
|
||||
},
|
||||
)
|
||||
RegisterWithGroupAndMeta("getNodesLatestStatus", "common",
|
||||
@@ -220,21 +222,21 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
req.BindParams(¶ms)
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
cinfo, err := client.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
|
||||
}
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
SendIpAddrToGuest, _ := config.GetAs[bool](config.SendIpAddrToGuestKey)
|
||||
if meta.Principal == nil || !meta.Principal.HasRole(rpc.RoleAdmin) {
|
||||
cfg, _ := conf.GetWithV1Format()
|
||||
if meta.Permission != "admin" {
|
||||
// 过滤 Hidden 节点并隐藏敏感字段
|
||||
filtered := make([]models.Client, 0, len(cinfo))
|
||||
for _, node := range cinfo {
|
||||
if node.Hidden { // 非 admin 不显示隐藏节点
|
||||
continue
|
||||
}
|
||||
if SendIpAddrToGuest {
|
||||
if cfg.SendIpAddrToGuest {
|
||||
if node.IPv4 != "" {
|
||||
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
|
||||
}
|
||||
@@ -262,7 +264,11 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
|
||||
}
|
||||
|
||||
return cinfo, nil
|
||||
nodesMap := make(map[string]models.Client, len(cinfo))
|
||||
for _, node := range cinfo {
|
||||
nodesMap[node.UUID] = node
|
||||
}
|
||||
return nodesMap, nil
|
||||
}
|
||||
|
||||
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
@@ -281,16 +287,16 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
|
||||
req.BindParams(¶ms)
|
||||
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
latest := agent_runtime.GetLatestReport() // map[string]*v1.Report (copy)
|
||||
onlineUUIDs := agent_runtime.GetAllOnlineUUIDs()
|
||||
onlineSet := make(map[string]bool, len(onlineUUIDs))
|
||||
for _, uuid := range onlineUUIDs {
|
||||
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
|
||||
connected := ws.GetConnectedClients()
|
||||
onlineSet := make(map[string]bool, len(connected))
|
||||
for uuid := range connected {
|
||||
onlineSet[uuid] = true
|
||||
}
|
||||
|
||||
// Hidden 过滤
|
||||
if meta.Principal == nil || !meta.Principal.HasRole(rpc.RoleAdmin) {
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
if meta.Permission != "admin" {
|
||||
cinfo, err := client.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
|
||||
}
|
||||
@@ -346,7 +352,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
|
||||
// 预取所有 ping 任务
|
||||
pingTasks, _ := tasks.GetAllPingTasks()
|
||||
|
||||
appendOne := func(uuid string, rep *v1.Report) {
|
||||
appendOne := func(uuid string, rep *common.Report) {
|
||||
if rep == nil {
|
||||
return
|
||||
}
|
||||
@@ -415,13 +421,8 @@ func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError)
|
||||
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
switch meta.Principal.Type {
|
||||
case rpc.PrincipalUser, rpc.PrincipalAPIKey:
|
||||
if meta.User == nil {
|
||||
resp.LoggedIn = true
|
||||
resp.Username = "api_key"
|
||||
return resp, nil
|
||||
}
|
||||
switch meta.Permission {
|
||||
case "admin":
|
||||
resp.TwoFAEnabled = meta.User.TwoFactor != ""
|
||||
resp.LoggedIn = true
|
||||
resp.SSOId = meta.User.SSOID
|
||||
@@ -429,23 +430,22 @@ func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError)
|
||||
resp.Username = meta.User.Username
|
||||
resp.UUID = meta.User.UUID
|
||||
return resp, nil
|
||||
case rpc.PrincipalAnonymous:
|
||||
case "guest":
|
||||
resp.LoggedIn = false
|
||||
return resp, nil
|
||||
case rpc.PrincipalAgent:
|
||||
case "client":
|
||||
resp.LoggedIn = true
|
||||
resp.SSOId = "client"
|
||||
resp.SSOType = "client"
|
||||
resp.Username = "client"
|
||||
resp.UUID = meta.ClientToken
|
||||
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
|
||||
client, err := client.GetClientUUIDByToken(meta.ClientToken)
|
||||
if err != nil {
|
||||
resp.UUID = client
|
||||
}
|
||||
return resp, nil
|
||||
default:
|
||||
resp.LoggedIn = false
|
||||
return resp, nil
|
||||
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid user role", meta.Permission)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,8 +454,8 @@ func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcErro
|
||||
Version string `json:"version"`
|
||||
Hash string `json:"hash"`
|
||||
}{
|
||||
Version: utils.CurrentVersion,
|
||||
Hash: utils.VersionHash,
|
||||
Version: conf.Version,
|
||||
Hash: conf.CommitHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
if meta.Principal != nil && meta.Principal.HasRole(rpc.RoleAdmin) {
|
||||
if meta.Permission == "admin" {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
@@ -489,8 +489,8 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := report_cache.Records.Get(params.UUID)
|
||||
reports, _ := raw.([]v1.Report)
|
||||
raw, _ := vars.Records.Get(params.UUID)
|
||||
reports, _ := raw.([]common.Report)
|
||||
|
||||
// 扁平化为 { count, records: [] }
|
||||
type flatRecord struct {
|
||||
@@ -1,4 +1,4 @@
|
||||
package jsonrpc
|
||||
package api_rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
recordsdb "github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
recordsdb "github.com/komari-monitor/komari/internal/database/records"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
@@ -70,10 +70,10 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
}
|
||||
|
||||
// Hidden filtering for non-admin
|
||||
isAdmin := meta.Principal != nil && meta.Principal.HasRole(rpc.RoleAdmin)
|
||||
isAdmin := meta.Permission == "admin"
|
||||
hidden := map[string]bool{}
|
||||
if !isAdmin {
|
||||
cinfo, err := clients.GetAllClientBasicInfo()
|
||||
cinfo, err := client.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
|
||||
}
|
||||
@@ -294,7 +294,14 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
|
||||
if !t.AppliesToClient(params.UUID) {
|
||||
assigned := false
|
||||
for _, c := range t.Clients {
|
||||
if c == params.UUID {
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assigned {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -384,7 +391,6 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"interval": t.Interval,
|
||||
"default_on": t.DefaultOn,
|
||||
"loss": lossRate,
|
||||
"min": minLat,
|
||||
"max": maxLat,
|
||||
@@ -0,0 +1,15 @@
|
||||
package api_rpc
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
)
|
||||
|
||||
func init() {
|
||||
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
|
||||
r := e.Get("engine").(*gin.Engine)
|
||||
RegisterRouters("/api/rpc2", r)
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/admin") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 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 {
|
||||
resp.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Komari is a single user system
|
||||
uuid, err := accounts.GetSession(session)
|
||||
if err != nil {
|
||||
resp.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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/api_v1/vars"
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/patrickmn/go-cache"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
)
|
||||
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute).Unix()
|
||||
var records []models.Record
|
||||
var gpuRecords []models.GPURecord
|
||||
|
||||
// 遍历所有客户端记录
|
||||
for uuid, x := range vars.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
vars.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
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
cfg, err := conf.GetWithV1Format()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+cfg.ApiKey
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
requireAuthPaths = []string{
|
||||
// v1 API at 2025-12-06
|
||||
// "/api/rpc2",
|
||||
// "/ping",
|
||||
// "/api/login",
|
||||
// "/api/me",
|
||||
"/api/clients",
|
||||
"/api/nodes",
|
||||
// "/api/public",
|
||||
// "/api/oauth",
|
||||
// "/api/oauth_callback",
|
||||
// "/api/logout",
|
||||
// "/api/version",
|
||||
"/api/recent/",
|
||||
"/api/records/",
|
||||
"/api/task/",
|
||||
// "/api/clients/register",
|
||||
// "/api/clients/report",
|
||||
// "/api/clients/uploadBasicInfo",
|
||||
// "/api/clients/terminal",
|
||||
// "/api/clients/task/result",
|
||||
// "/api/admin/download/backup",
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
// 如果不是 /api 开头的路径,直接放行
|
||||
if !strings.HasPrefix(path, "/api") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// /api/clients/* 由 TokenAuthMiddleware 处理
|
||||
// /api/rpc2 由 api_rpc 处理
|
||||
// /api/admin/* 由 AdminAuthMiddleware 处理
|
||||
if strings.HasPrefix(path, "/api/clients/") ||
|
||||
strings.HasPrefix(path, "/api/admin") ||
|
||||
strings.HasPrefix(path, "/api/rpc2") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
require := false
|
||||
for _, p := range requireAuthPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
require = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !require {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
conf, err := conf.GetWithV1Format()
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 验证私有, 如果不是私有站点,直接放行
|
||||
if !conf.PrivateSite {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 如果是私有站点,检查是否有 session
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
_, err = accounts.GetSession(session)
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"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
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"image/png"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func Generate2FA(c *gin.Context) {
|
||||
secret, img, err := accounts.Generate2Fa()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
|
||||
resp.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
|
||||
@@ -26,29 +26,29 @@ func Enable2FA(c *gin.Context) {
|
||||
secret, _ := c.Cookie("2fa_secret")
|
||||
code := c.Query("code")
|
||||
if secret == "" || uuid == nil || code == "" {
|
||||
api.RespondError(c, 400, "2FA secret or code not provided")
|
||||
resp.RespondError(c, 400, "2FA secret or code not provided")
|
||||
return
|
||||
}
|
||||
if !totp.Validate(code, secret) {
|
||||
api.RespondError(c, 400, "Invalid 2FA code")
|
||||
resp.RespondError(c, 400, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
err := accounts.Enable2Fa(uuid.(string), secret)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
|
||||
resp.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
|
||||
|
||||
api.RespondSuccess(c, "2FA enabled successfully")
|
||||
resp.RespondSuccess(c, "2FA enabled successfully")
|
||||
}
|
||||
|
||||
func Disable2FA(c *gin.Context) {
|
||||
uuid, _ := c.Get("uuid")
|
||||
err := accounts.Disable2Fa(uuid.(string))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
|
||||
resp.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, "")
|
||||
resp.RespondSuccess(c, "")
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/database/records"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/internal/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")
|
||||
event.Trigger(eventType.ClientCreated, event.M{
|
||||
"client": uuid,
|
||||
"token": token,
|
||||
})
|
||||
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.PartialUpdate(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")
|
||||
event.Trigger(eventType.ClientUpdated, event.M{
|
||||
"client": uuid,
|
||||
"data": req,
|
||||
})
|
||||
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)
|
||||
event.Trigger(eventType.ClientDeleted, event.M{
|
||||
"client": 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:": ""})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
func OrderWeight(c *gin.Context) {
|
||||
var req = make(map[string]int)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to update client weight: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
clipboardDB "github.com/komari-monitor/komari/internal/database/clipboard"
|
||||
"github.com/komari-monitor/komari/internal/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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
cb, err := clipboardDB.GetClipboardByID(id)
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, cb)
|
||||
}
|
||||
|
||||
// ListClipboard lists all clipboard entries
|
||||
func ListClipboard(c *gin.Context) {
|
||||
list, err := clipboardDB.ListClipboard()
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.CreateClipboard(&req); err != nil {
|
||||
resp.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")
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&fields); err != nil {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
|
||||
resp.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")
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboard(id); err != nil {
|
||||
resp.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")
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
|
||||
return
|
||||
}
|
||||
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
|
||||
resp.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")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
// copyFile 复制单个文件到目标路径(会确保父目录存在)
|
||||
@@ -108,35 +108,35 @@ 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))
|
||||
resp.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))
|
||||
resp.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
|
||||
dbFilePath := conf.Conf.Database.DatabaseFile
|
||||
|
||||
if flags.IsSQLite() {
|
||||
if conf.Conf.Database.DatabaseType == "sqlite" || conf.Conf.Database.DatabaseType == "" {
|
||||
if err := backupSQLiteTo(destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
|
||||
resp.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))
|
||||
resp.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))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func DownloadBackup(c *gin.Context) {
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,11 +200,11 @@ func DownloadBackup(c *gin.Context) {
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
|
||||
resp.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))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
)
|
||||
|
||||
// 接受数据类型:
|
||||
// - 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 {
|
||||
resp.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 {
|
||||
// // resp.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 {
|
||||
resp.RespondError(c, 400, "No clients connected")
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 400, "Client connection is broke: "+uuid)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
resp.RespondError(c, 400, "Client connection is null: "+uuid)
|
||||
return
|
||||
}
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
|
||||
resp.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
|
||||
// }
|
||||
@@ -0,0 +1,49 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
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 {
|
||||
resp.RespondError(c, 400, "Invalid limit: "+limit)
|
||||
return
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil || pageInt <= 0 {
|
||||
resp.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 {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, gin.H{"logs": logs, "total": total})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/messageSender"
|
||||
"github.com/komari-monitor/komari/internal/messageSender/factory"
|
||||
)
|
||||
|
||||
func GetMessageSenderProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetMessageSenderConfigByName(provider)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置项模板
|
||||
providers := factory.GetSenderConfigs()
|
||||
if len(providers) == 0 {
|
||||
resp.RespondError(c, 404, "No message sender providers found")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetMessageSenderProvider(c *gin.Context) {
|
||||
var senderConfig models.MessageSenderProvider
|
||||
if err := c.ShouldBindJSON(&senderConfig); err != nil {
|
||||
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if senderConfig.Name == "" {
|
||||
resp.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(senderConfig.Name)
|
||||
if !exists {
|
||||
resp.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
|
||||
return
|
||||
}
|
||||
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
|
||||
resp.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := conf.GetWithV1Format()
|
||||
// 正在使用,重载
|
||||
if cfg.NotificationMethod == senderConfig.Name {
|
||||
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Interval > 4*60 || req.Interval <= 0 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
|
||||
return
|
||||
}
|
||||
if req.Ratio <= 0 || req.Ratio > 1 {
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.DeleteLoadNotification(req.ID); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.EditLoadNotification(req.Notifications); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, notification := range req.Notifications {
|
||||
// notification.DeleteLoadNotification([]uint{notification.Id})
|
||||
// }
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications(c *gin.Context) {
|
||||
notifications, err := notification.GetAllLoadNotifications()
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
resp "github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// POST body : []uuid
|
||||
func EnableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// POST body : []uuid
|
||||
func DisableOfflineNotification(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func EditOfflineNotification(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
if err := c.ShouldBindJSON(¬ifications); err != nil {
|
||||
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
resp.RespondError(c, 400, "At least one notification is required")
|
||||
return
|
||||
}
|
||||
for _, noti := range notifications {
|
||||
if noti.Client == "" {
|
||||
resp.RespondError(c, 400, "Client UUID cannot be empty")
|
||||
return
|
||||
}
|
||||
if noti.GracePeriod <= 0 {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func ListOfflineNotifications(c *gin.Context) {
|
||||
var notifications []models.OfflineNotification
|
||||
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(¬ifications).Error
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/oauth"
|
||||
"github.com/komari-monitor/komari/internal/oauth/factory"
|
||||
)
|
||||
|
||||
func BindingExternalAccount(c *gin.Context) {
|
||||
session, _ := c.Cookie("session_token")
|
||||
user, err := accounts.GetUserBySession(session)
|
||||
if err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "No user found: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = accounts.UnbindExternalAccount(user.UUID)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func GetOidcProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetOidcConfigByName(provider)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置
|
||||
providers := factory.GetProviderConfigs()
|
||||
if len(providers) == 0 {
|
||||
resp.RespondError(c, 404, "No OIDC providers found")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetOidcProvider(c *gin.Context) {
|
||||
var oidcConfig models.OidcProvider
|
||||
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
|
||||
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if oidcConfig.Name == "" {
|
||||
resp.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(oidcConfig.Name)
|
||||
if !exists {
|
||||
resp.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
|
||||
resp.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := conf.GetWithV1Format()
|
||||
// 正在使用,重载
|
||||
if cfg.OAuthProvider == oidcConfig.Name {
|
||||
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.DeletePingTask(req.ID); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
resp.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 {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.EditPingTask(req.Tasks); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, task := range req.Tasks {
|
||||
// tasks.DeletePingRecords([]uint{task.Id})
|
||||
// }
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllPingTasks(c *gin.Context) {
|
||||
tasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.RespondSuccess(c, tasks)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetSessions(c *gin.Context) {
|
||||
|
||||
ss, err := accounts.GetAllSessions()
|
||||
if err != nil {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 400, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
err := accounts.DeleteSession(req.Session)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to delete session: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteAllSession(c *gin.Context) {
|
||||
|
||||
err := accounts.DeleteAllSessions()
|
||||
if err != nil {
|
||||
resp.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")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/database/records"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetSettings 获取自定义配置
|
||||
func GetSettings(c *gin.Context) {
|
||||
cst, err := conf.GetWithV1Format()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
//override
|
||||
cst = conf.V1Struct{Sitename: "Komari"}
|
||||
cst.ID = 1
|
||||
conf.Save(cst)
|
||||
resp.RespondSuccess(c, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
}
|
||||
resp.RespondSuccess(c, cst)
|
||||
}
|
||||
|
||||
// EditSettings 更新自定义配置
|
||||
func EditSettings(c *gin.Context) {
|
||||
cfg := make(map[string]interface{})
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := conf.Update(cfg); err != nil {
|
||||
resp.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
ignoredKeys := []string{"id", "updated_at"}
|
||||
if contains(ignoredKeys, key) {
|
||||
continue
|
||||
}
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
message = message[:len(message)-2]
|
||||
}
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, v := range slice {
|
||||
if v == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ClearAllRecords(c *gin.Context) {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
)
|
||||
|
||||
func GetTasks(c *gin.Context) {
|
||||
dbTasks, err := tasks.GetAllTasks()
|
||||
if err != nil {
|
||||
resp.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 {
|
||||
resp.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,
|
||||
})
|
||||
}
|
||||
resp.RespondSuccess(c, responseTasks)
|
||||
}
|
||||
|
||||
func GetTaskById(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
resp.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
task, err := tasks.GetTaskByTaskId(taskId)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
resp.RespondError(c, 404, "Task not found")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
resp.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,
|
||||
})
|
||||
}
|
||||
resp.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 == "" {
|
||||
resp.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
tasks, err := tasks.GetTasksByClientId(clientId)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
resp.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, tasks)
|
||||
}
|
||||
|
||||
func GetSpecificTaskResult(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
clientId := c.Param("uuid")
|
||||
if taskId == "" || clientId == "" {
|
||||
resp.RespondError(c, 400, "Task ID and Client ID are required")
|
||||
return
|
||||
}
|
||||
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
resp.RespondError(c, 404, "No result found for this task and client")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, result)
|
||||
}
|
||||
|
||||
// Param: task_id
|
||||
func GetTaskResultsByTaskId(c *gin.Context) {
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
resp.RespondError(c, 400, "Task ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetTaskResultsByTaskId(taskId)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
resp.RespondError(c, 404, "No results found for this task")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, results)
|
||||
}
|
||||
|
||||
func GetAllTaskResultByUUID(c *gin.Context) {
|
||||
clientId := c.Param("uuid")
|
||||
if clientId == "" {
|
||||
resp.RespondError(c, 400, "Client ID is required")
|
||||
return
|
||||
}
|
||||
results, err := tasks.GetAllTasksResultByUUID(clientId)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(results) == 0 {
|
||||
resp.RespondError(c, 404, "No tasks found for this client")
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, results)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/geoip"
|
||||
"github.com/komari-monitor/komari/internal/messageSender"
|
||||
)
|
||||
|
||||
func TestSendMessage(c *gin.Context) {
|
||||
err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: "Test",
|
||||
Message: "This is a test message from Komari.",
|
||||
})
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to send message: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func TestGeoIp(c *gin.Context) {
|
||||
ip := c.Query("ip")
|
||||
if ip == "" {
|
||||
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
|
||||
ip = cfIP
|
||||
} else {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := conf.GetWithV1Format()
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
resp.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, GeoIpRecord)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,11 +13,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/web/public"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
// UploadTheme 上传主题
|
||||
@@ -26,32 +24,32 @@ func UploadTheme(c *gin.Context) {
|
||||
// 读取上传的文件内容
|
||||
data, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil || len(data) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
|
||||
resp.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// 检查文件扩展名(这里假定上传的就是zip)
|
||||
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
|
||||
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
|
||||
resp.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 解压ZIP文件并验证
|
||||
themeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
|
||||
resp.RespondSuccessMessage(c, "主题上传成功", themeInfo)
|
||||
}
|
||||
|
||||
// ListThemes 列出所有主题
|
||||
@@ -60,26 +58,17 @@ func ListThemes(c *gin.Context) {
|
||||
|
||||
// 确保主题目录存在
|
||||
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
|
||||
api.RespondSuccess(c, []models.Theme{})
|
||||
resp.RespondSuccess(c, []models.Theme{})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var themes []models.Theme
|
||||
defaultTheme, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err == nil {
|
||||
dt := models.Theme{}
|
||||
err := json.Unmarshal(defaultTheme, &dt)
|
||||
if err == nil {
|
||||
themes = append(themes, dt)
|
||||
}
|
||||
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
|
||||
@@ -89,7 +78,7 @@ func ListThemes(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, themes)
|
||||
resp.RespondSuccess(c, themes)
|
||||
}
|
||||
|
||||
// DeleteTheme 删除主题
|
||||
@@ -99,18 +88,12 @@ func DeleteTheme(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Short == "default" {
|
||||
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验主题短名称,防止路径穿越(如 ../)导致删除工作目录外的任意文件
|
||||
if !isValidThemeShort(req.Short) {
|
||||
api.RespondError(c, http.StatusBadRequest, "无效的主题名称")
|
||||
resp.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,49 +101,49 @@ func DeleteTheme(c *gin.Context) {
|
||||
|
||||
// 检查主题是否存在
|
||||
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
resp.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除主题目录
|
||||
if err := os.RemoveAll(themeDir); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题删除成功", nil)
|
||||
resp.RespondSuccessMessage(c, "主题删除成功", nil)
|
||||
}
|
||||
|
||||
// SetTheme 设置主题
|
||||
func SetTheme(c *gin.Context) {
|
||||
themeName := c.Query("theme")
|
||||
if themeName == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不是default主题,检查主题是否存在
|
||||
if themeName != "default" {
|
||||
// 校验主题名称,防止路径穿越(如 ../)访问工作目录外的文件
|
||||
if !isValidThemeShort(themeName) {
|
||||
api.RespondError(c, http.StatusBadRequest, "无效的主题名称")
|
||||
return
|
||||
}
|
||||
themeDir := filepath.Join("./data/theme", themeName)
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
resp.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Set("theme", themeName); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
|
||||
// 更新配置
|
||||
updateData := map[string]interface{}{
|
||||
"theme": themeName,
|
||||
}
|
||||
|
||||
if err := conf.Update(updateData); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
|
||||
resp.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
|
||||
}
|
||||
|
||||
// extractAndValidateTheme 解压并验证主题
|
||||
@@ -213,10 +196,6 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
if err := themeInfo.ValidateConfiguration(); err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
// 创建主题目录
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
|
||||
@@ -307,39 +286,9 @@ func isValidThemeShort(short string) bool {
|
||||
}
|
||||
|
||||
// downloadThemeFromURL 从URL下载主题文件
|
||||
// isPrivateIP checks if the resolved IP addresses are private/internal
|
||||
func isPrivateIP(host string) bool {
|
||||
ips, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return true // fail closed
|
||||
}
|
||||
for _, ipStr := range ips {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func downloadThemeFromURL(rawURL string) ([]byte, error) {
|
||||
// SSRF protection: block requests to private/internal IPs
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("only http and https schemes are allowed")
|
||||
}
|
||||
if isPrivateIP(parsedURL.Hostname()) {
|
||||
return nil, fmt.Errorf("requests to private/internal addresses are not allowed")
|
||||
}
|
||||
|
||||
func downloadThemeFromURL(url string) ([]byte, error) {
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(rawURL)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载主题文件失败: %v", err)
|
||||
}
|
||||
@@ -482,13 +431,7 @@ func UpdateTheme(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验主题短名称,防止路径穿越(如 ../)访问工作目录外的文件
|
||||
if !isValidThemeShort(req.Short) {
|
||||
api.RespondError(c, http.StatusBadRequest, "无效的主题名称")
|
||||
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -497,14 +440,14 @@ func UpdateTheme(c *gin.Context) {
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
resp.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 加载现有主题配置
|
||||
themeInfo, err := loadThemeConfig(themeConfigPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -551,14 +494,14 @@ func UpdateTheme(c *gin.Context) {
|
||||
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存下载链接,稍后更新到主题配置中
|
||||
@@ -572,14 +515,14 @@ func UpdateTheme(c *gin.Context) {
|
||||
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
|
||||
@@ -590,7 +533,7 @@ func UpdateTheme(c *gin.Context) {
|
||||
// 新URL不是GitHub仓库地址,直接尝试下载
|
||||
themeData, err = downloadThemeFromURL(req.URL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// downloadURL = req.URL
|
||||
@@ -600,7 +543,7 @@ func UpdateTheme(c *gin.Context) {
|
||||
|
||||
// 如果没有成功下载主题数据
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
|
||||
resp.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -613,7 +556,7 @@ func UpdateTheme(c *gin.Context) {
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
@@ -621,7 +564,7 @@ func UpdateTheme(c *gin.Context) {
|
||||
// 解压ZIP文件并验证
|
||||
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -633,169 +576,23 @@ func UpdateTheme(c *gin.Context) {
|
||||
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
|
||||
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
|
||||
// if err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
// resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
|
||||
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
|
||||
// resp.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
|
||||
}
|
||||
|
||||
// peekThemeFromZip 仅从ZIP文件中读取komari-theme.json并解析主题信息
|
||||
// 不执行解压安装,用于preview模式
|
||||
func peekThemeFromZip(zipPath string) (models.Theme, error) {
|
||||
var themeInfo models.Theme
|
||||
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
var themeConfigFile *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.Name == "komari-theme.json" {
|
||||
themeConfigFile = f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if themeConfigFile == nil {
|
||||
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在,不是合法的主题包")
|
||||
}
|
||||
|
||||
rc, err := themeConfigFile.Open()
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
configData, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configData, &themeInfo); err != nil {
|
||||
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
|
||||
}
|
||||
|
||||
if themeInfo.Name == "" || themeInfo.Short == "" {
|
||||
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
|
||||
}
|
||||
|
||||
if !isValidThemeShort(themeInfo.Short) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
if err := themeInfo.ValidateConfiguration(); err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
return themeInfo, nil
|
||||
}
|
||||
|
||||
// ImportTheme 导入远程主题
|
||||
// 支持preview查询参数:preview=true时仅返回主题信息,否则下载安装
|
||||
// 请求body: {"url": "https://..."}
|
||||
// URL支持GitHub仓库地址(自动取latest release)和直接ZIP下载链接
|
||||
func ImportTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析下载链接
|
||||
downloadURL := req.URL
|
||||
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
|
||||
if isGitHub {
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
downloadURL = gitHubURL
|
||||
}
|
||||
|
||||
// 下载主题ZIP
|
||||
themeData, err := downloadThemeFromURL(downloadURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到临时文件
|
||||
tempFile := filepath.Join(os.TempDir(), "import_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// preview模式:仅解析并返回主题信息
|
||||
preview := c.Query("preview")
|
||||
if preview == "true" {
|
||||
themeInfo, err := peekThemeFromZip(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已存在同名主题
|
||||
exists := false
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
if _, err := os.Stat(themeDir); err == nil {
|
||||
exists = true
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"theme": themeInfo,
|
||||
"exists": exists,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 安装模式:检查是否存在同名主题
|
||||
// 先peek一下获取short名称用于检测冲突
|
||||
themeInfo, err := peekThemeFromZip(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
overwritten := false
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
if _, err := os.Stat(themeDir); err == nil {
|
||||
overwritten = true
|
||||
}
|
||||
|
||||
// 解压安装
|
||||
installedTheme, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
msg := "主题导入成功"
|
||||
if overwritten {
|
||||
msg = "主题导入成功(已覆盖同名主题)"
|
||||
}
|
||||
|
||||
api.RespondSuccessMessage(c, msg, installedTheme)
|
||||
resp.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
|
||||
}
|
||||
|
||||
func UpdateThemeSettings(c *gin.Context) {
|
||||
theme := c.Query("theme")
|
||||
if theme == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
if theme == "" || theme == "default" {
|
||||
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -803,14 +600,14 @@ func UpdateThemeSettings(c *gin.Context) {
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
data, err := json.Marshal(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -818,5 +615,5 @@ func UpdateThemeSettings(c *gin.Context) {
|
||||
db.Where("short = ?", theme).
|
||||
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
|
||||
FirstOrCreate(&themeCfg)
|
||||
api.RespondSuccess(c, nil)
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/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") {
|
||||
resp.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
|
||||
} else {
|
||||
resp.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func DeleteFavicon(c *gin.Context) {
|
||||
if err := os.Remove("./data/favicon.ico"); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
resp.RespondError(c, http.StatusNotFound, "Favicon not found")
|
||||
} else {
|
||||
resp.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/geoip"
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.UpdateDatabase(); err != nil {
|
||||
resp.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")
|
||||
resp.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/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 {
|
||||
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Password == nil && req.Name == nil {
|
||||
resp.RespondError(c, 400, "At least one field (username or password) must be provided")
|
||||
return
|
||||
}
|
||||
if req.Name != nil && len(*req.Name) < 3 {
|
||||
resp.RespondError(c, 400, "Username must be at least 3 characters long")
|
||||
return
|
||||
}
|
||||
if req.Password != nil && len(*req.Password) < 6 {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 500, "Failed to update user: "+err.Error())
|
||||
return
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
|
||||
resp.RespondSuccess(c, gin.H{"uuid": req.Uuid})
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
)
|
||||
|
||||
// 只有一个备份恢复操作在进行
|
||||
@@ -23,7 +23,7 @@ var restoreMutex sync.Mutex
|
||||
func UploadBackup(c *gin.Context) {
|
||||
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
|
||||
if !restoreMutex.TryLock() {
|
||||
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
|
||||
resp.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
|
||||
return
|
||||
}
|
||||
defer restoreMutex.Unlock()
|
||||
@@ -31,27 +31,27 @@ func UploadBackup(c *gin.Context) {
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("backup")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
|
||||
resp.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")
|
||||
resp.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))
|
||||
resp.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))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFilePath := tempFile.Name()
|
||||
@@ -61,7 +61,7 @@ func UploadBackup(c *gin.Context) {
|
||||
_, err = io.Copy(tempFile, file)
|
||||
if err != nil {
|
||||
tempFile.Close()
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFile.Close() // 关闭文件以便后续操作
|
||||
@@ -77,11 +77,11 @@ func UploadBackup(c *gin.Context) {
|
||||
}
|
||||
zr.Close()
|
||||
if !hasMarkup {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
|
||||
resp.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))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,18 +93,18 @@ func UploadBackup(c *gin.Context) {
|
||||
// fallback:拷贝
|
||||
in, err2 := os.Open(tempFilePath)
|
||||
if err2 != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
|
||||
resp.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))
|
||||
resp.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))
|
||||
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
)
|
||||
|
||||
func RegisterClient(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
cfg, err := conf.GetWithV1Format()
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.AutoDiscoveryKey == "" ||
|
||||
len(cfg.AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+cfg.AutoDiscoveryKey != auth {
|
||||
|
||||
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
name = utils.GenerateRandomString(8)
|
||||
}
|
||||
name = "Auto-" + name
|
||||
uuid, token, err := clients.CreateClientWithName(name)
|
||||
if err != nil {
|
||||
resp.RespondError(c, 500, "Failed to create client: "+err.Error())
|
||||
return
|
||||
}
|
||||
event.Trigger(eventType.ClientCreated, event.M{
|
||||
"client": uuid,
|
||||
"token": token,
|
||||
})
|
||||
resp.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
|
||||
}
|
||||
@@ -5,98 +5,31 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
report_cache "github.com/komari-monitor/komari/web/report"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/vars"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/internal/notifier"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// 如果超过这个时间没有收到任何消息,则认为连接已死
|
||||
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
|
||||
readWait = 11 * time.Second
|
||||
postPresenceTTL = 35 * time.Second
|
||||
readWait = 11 * time.Second
|
||||
)
|
||||
|
||||
// postPresenceEntry 保存单个客户端的 POST 上报会话状态
|
||||
type postPresenceEntry struct {
|
||||
connID int64
|
||||
timer *time.Timer
|
||||
generation uint64 // 每次 Reset 递增,用于回调中判断是否为过期的旧回调
|
||||
}
|
||||
|
||||
var (
|
||||
postPresenceMu sync.Mutex
|
||||
postPresenceStates = make(map[string]*postPresenceEntry)
|
||||
)
|
||||
|
||||
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
|
||||
// 每次 POST 刷新 TTL 定时器;定时器到期后触发离线通知。
|
||||
func refreshPostPresence(uuid string) {
|
||||
postPresenceMu.Lock()
|
||||
defer postPresenceMu.Unlock()
|
||||
|
||||
if entry, exists := postPresenceStates[uuid]; exists {
|
||||
// 已在线:递增 generation 使可能正在执行的旧回调失效
|
||||
entry.generation++
|
||||
entry.timer.Stop()
|
||||
// 重新创建 AfterFunc 以在闭包中捕获新的 generation
|
||||
gen := entry.generation
|
||||
entry.timer = time.AfterFunc(postPresenceTTL, func() {
|
||||
postPresenceExpired(uuid, entry.connID, gen)
|
||||
})
|
||||
agent_runtime.KeepAlivePresence(uuid, entry.connID, postPresenceTTL)
|
||||
return
|
||||
}
|
||||
|
||||
// 新 POST 会话:生成 connID,标记在线,启动超时定时器
|
||||
connID := time.Now().UnixNano()
|
||||
agent_runtime.KeepAlivePresence(uuid, connID, postPresenceTTL)
|
||||
agent_runtime.SetClientProtocolVersion(uuid, 1)
|
||||
go notifier.OnlineNotification(uuid, connID)
|
||||
|
||||
defaultGeneration := uint64(0)
|
||||
|
||||
entry := &postPresenceEntry{
|
||||
connID: connID,
|
||||
generation: defaultGeneration,
|
||||
}
|
||||
|
||||
entry.timer = time.AfterFunc(postPresenceTTL, func() {
|
||||
postPresenceExpired(uuid, connID, defaultGeneration)
|
||||
})
|
||||
|
||||
postPresenceStates[uuid] = entry
|
||||
}
|
||||
|
||||
// postPresenceExpired 是定时器到期的回调。
|
||||
// 只有当 connID 和 generation 都与当前 entry 匹配时才执行离线清理,
|
||||
// 避免 timer.Reset 竞态导致过期回调错误地清除仍活跃的会话。
|
||||
func postPresenceExpired(uuid string, connID int64, gen uint64) {
|
||||
postPresenceMu.Lock()
|
||||
e, ok := postPresenceStates[uuid]
|
||||
if !ok || e.connID != connID || e.generation != gen {
|
||||
postPresenceMu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(postPresenceStates, uuid)
|
||||
postPresenceMu.Unlock()
|
||||
|
||||
agent_runtime.SetPresence(uuid, connID, false)
|
||||
notifier.OfflineNotification(uuid, connID)
|
||||
}
|
||||
|
||||
func UploadReport(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
@@ -112,29 +45,21 @@ func UploadReport(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// Save report to database
|
||||
var report v1.Report
|
||||
var report common.Report
|
||||
err = json.Unmarshal(bodyBytes, &report)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
// 优先使用 body 中的 UUID,若为空则从中间件注入的上下文中获取
|
||||
uuid := report.UUID
|
||||
if uuid == "" {
|
||||
if v, ok := c.Get("client_uuid"); ok {
|
||||
uuid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if uuid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// POST 上报:落库、更新运行时状态并刷新在线状态
|
||||
if err := ingestReport(uuid, report, 1, true); err != nil {
|
||||
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"})
|
||||
@@ -142,17 +67,22 @@ func UploadReport(c *gin.Context) {
|
||||
|
||||
func WebSocketReport(c *gin.Context) {
|
||||
// 升级ws
|
||||
if !api.IsWebSocketUpgrade(c) {
|
||||
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 := api.UpgradeWebSocket(c)
|
||||
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()})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
conn := connection.NewSafeConn(unsafeConn)
|
||||
conn := ws.NewSafeConn(unsafeConn)
|
||||
defer conn.Close()
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
@@ -188,20 +118,25 @@ func WebSocketReport(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 接受新连接,并处理旧连接
|
||||
if oldConn, exists := agent_runtime.GetConnectedClients()[uuid]; exists {
|
||||
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
|
||||
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
|
||||
|
||||
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
|
||||
go oldConn.Close()
|
||||
}
|
||||
agent_runtime.SetConnectedClients(uuid, conn)
|
||||
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
|
||||
ws.SetConnectedClients(uuid, conn)
|
||||
//log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
|
||||
go notifier.OnlineNotification(uuid, conn.ID)
|
||||
defer func() {
|
||||
agent_runtime.DeleteClientConditionally(uuid, conn)
|
||||
ws.DeleteClientConditionally(uuid, conn)
|
||||
notifier.OfflineNotification(uuid, conn.ID)
|
||||
}()
|
||||
|
||||
event.Trigger(eventType.ClientWebsocketConnected, event.M{
|
||||
"client": uuid,
|
||||
"online": len(ws.GetConnectedClients()),
|
||||
"connID": conn.ID,
|
||||
})
|
||||
// 首先处理第一次ws conn收到的消息
|
||||
processMessage(conn, message, uuid)
|
||||
|
||||
@@ -215,12 +150,22 @@ func WebSocketReport(c *gin.Context) {
|
||||
}
|
||||
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
|
||||
}
|
||||
event.Trigger(eventType.ClientMessageReceived, event.M{
|
||||
"client": uuid,
|
||||
"type": "websocket",
|
||||
"message": string(message),
|
||||
})
|
||||
processMessage(conn, message, uuid)
|
||||
}
|
||||
event.Trigger(eventType.ClientWebsocketDisconnected, event.M{
|
||||
"client": uuid,
|
||||
"online": len(ws.GetConnectedClients()),
|
||||
"connID": conn.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// 将消息处理逻辑提取到一个函数中,方便复用
|
||||
func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
|
||||
func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
|
||||
type MessageType struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
@@ -233,17 +178,19 @@ func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
|
||||
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := v1.Report{}
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
return
|
||||
}
|
||||
// WS 连接自行管理在线状态,无需刷新 POST presence
|
||||
if err := ingestReport(uuid, report, 1, false); err != nil {
|
||||
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"`
|
||||
@@ -256,16 +203,29 @@ func processMessage(conn *connection.SafeConn, message []byte, uuid string) {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
|
||||
return
|
||||
}
|
||||
ingestPingResult(uuid, reqBody.PingTaskID, reqBody.PingResult, reqBody.FinishedAt)
|
||||
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 v1.Report) (v1.Report, error) {
|
||||
func SaveClientReport(uuid string, report common.Report) error {
|
||||
reports, _ := vars.Records.Get(uuid)
|
||||
if reports == nil {
|
||||
reports = []common.Report{}
|
||||
}
|
||||
if report.CPU.Usage < 0.01 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
return report_cache.AppendClientReport(uuid, report)
|
||||
reports = append(reports.([]common.Report), report)
|
||||
vars.Records.Set(uuid, reports, cache.DefaultExpiration)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
)
|
||||
|
||||
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"})
|
||||
event.Trigger(eventType.TaskUpdated, event.M{
|
||||
"task": req.TaskId,
|
||||
"client": clientId,
|
||||
"data": req,
|
||||
})
|
||||
}
|
||||
@@ -1,42 +1,48 @@
|
||||
package terminal
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/terminal"
|
||||
)
|
||||
|
||||
func EstablishConnection(c *gin.Context) {
|
||||
session_id := c.Query("id")
|
||||
session, exists := TerminalSessions[session_id]
|
||||
session, exists := terminal.TerminalSessions[session_id]
|
||||
if !exists || session == nil || session.Browser == nil {
|
||||
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
|
||||
return
|
||||
}
|
||||
// Upgrade the connection to WebSocket
|
||||
if !api.IsWebSocketUpgrade(c) {
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
conn, err := api.UpgradeWebSocket(c)
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
TerminalSessionsMutex.Lock()
|
||||
terminal.TerminalSessionsMutex.Lock()
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
delete(TerminalSessions, session_id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
delete(terminal.TerminalSessions, session_id)
|
||||
terminal.TerminalSessionsMutex.Unlock()
|
||||
return
|
||||
}
|
||||
session.Agent = conn
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
delete(TerminalSessions, session_id)
|
||||
delete(terminal.TerminalSessions, session_id)
|
||||
// 通知 Browser 关闭终端连接
|
||||
if session.Browser != nil {
|
||||
session.Browser.Close()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
go ForwardTerminal(session_id)
|
||||
go terminal.ForwardTerminal(session_id)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/gookit/event"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/internal/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 := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := clients.PartialUpdate(cbi); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": err})
|
||||
return
|
||||
}
|
||||
event.Trigger(eventType.ClientMessageReceived, event.M{
|
||||
"client": cbi["uuid"],
|
||||
"type": "basic_info",
|
||||
"data": cbi,
|
||||
})
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/admin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/admin/clipboard"
|
||||
log_api "github.com/komari-monitor/komari/internal/api_v1/admin/log"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/admin/notification"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/admin/test"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/admin/update"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/client"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/record"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/task"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/terminal"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
)
|
||||
|
||||
func init() {
|
||||
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
|
||||
r := e.Get("engine").(*gin.Engine)
|
||||
config, _ := conf.GetWithV1Format()
|
||||
LoadApiV1Routes(r, config)
|
||||
return nil
|
||||
}), event.Normal+5)
|
||||
|
||||
event.On(eventType.SchedulerEveryMinute, event.ListenerFunc(func(e event.Event) error {
|
||||
SaveClientReportToDB()
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
func LoadApiV1Routes(r *gin.Engine, conf conf.V1Struct) {
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(PrivateSiteMiddleware())
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
// #region 公开路由
|
||||
r.POST("/api/login", Login)
|
||||
r.GET("/api/me", GetMe)
|
||||
r.GET("/api/clients", GetClients)
|
||||
r.GET("/api/nodes", GetNodesInformation)
|
||||
r.GET("/api/public", GetPublicSettings)
|
||||
r.GET("/api/oauth", OAuth)
|
||||
r.GET("/api/oauth_callback", OAuthCallback)
|
||||
r.GET("/api/logout", Logout)
|
||||
r.GET("/api/version", resp.GetVersion)
|
||||
r.GET("/api/recent/:uuid", GetClientRecentRecords)
|
||||
r.GET("/api/records/load", record.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", record.GetPingRecords)
|
||||
r.GET("/api/task/ping", task.GetPublicPingTasks)
|
||||
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", 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 管理员
|
||||
r.Use(AdminAuthMiddleware())
|
||||
adminAuthrized := r.Group("/api/admin")
|
||||
{
|
||||
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", terminal.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
recordGroup.POST("/clear/all", admin.ClearAllRecords)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
|
||||
// clipboard
|
||||
clipboardGroup := adminAuthrized.Group("/clipboard")
|
||||
{
|
||||
clipboardGroup.GET("/:id", clipboard.GetClipboard)
|
||||
clipboardGroup.GET("", clipboard.ListClipboard)
|
||||
clipboardGroup.POST("", clipboard.CreateClipboard)
|
||||
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
|
||||
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
|
||||
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
|
||||
}
|
||||
|
||||
notificationGroup := adminAuthrized.Group("/notification")
|
||||
{
|
||||
// offline notifications
|
||||
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
loadAlertGroup := notificationGroup.Group("/load")
|
||||
{
|
||||
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
|
||||
loadAlertGroup.POST("/add", notification.AddLoadNotification)
|
||||
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
|
||||
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
|
||||
}
|
||||
}
|
||||
|
||||
pingTaskGroup := adminAuthrized.Group("/ping")
|
||||
{
|
||||
pingTaskGroup.GET("/", admin.GetAllPingTasks)
|
||||
pingTaskGroup.POST("/add", admin.AddPingTask)
|
||||
pingTaskGroup.POST("/delete", admin.DeletePingTask)
|
||||
pingTaskGroup.POST("/edit", admin.EditPingTask)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/vars"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
if uuid == "" {
|
||||
resp.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] {
|
||||
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
records, _ := vars.Records.Get(uuid)
|
||||
resp.RespondSuccess(c, records)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TwoFa string `json:"2fa_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := conf.GetWithV1Format()
|
||||
if conf.DisablePasswordLogin {
|
||||
resp.RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
var data LoginRequest
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if data.Username == "" || data.Password == "" {
|
||||
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
uuid, success := accounts.CheckPassword(data.Username, data.Password)
|
||||
if !success {
|
||||
resp.RespondError(c, http.StatusUnauthorized, "Invalid credentials")
|
||||
event.Trigger(eventType.LoginFailed, event.M{
|
||||
"username": data.Username,
|
||||
"method": "password",
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
})
|
||||
return
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" { // 开启了2FA
|
||||
if data.TwoFa == "" {
|
||||
resp.RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
|
||||
resp.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 {
|
||||
resp.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")
|
||||
resp.RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
|
||||
event.Trigger(eventType.UserLogin, event.M{
|
||||
"username": data.Username,
|
||||
"method": "password",
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
})
|
||||
}
|
||||
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")
|
||||
event.Trigger(eventType.UserLogout, event.M{
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
})
|
||||
c.Redirect(302, "/")
|
||||
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
package public
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -30,6 +29,11 @@ func TestLogin(t *testing.T) {
|
||||
Password: "correctpassword",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"set-cookie": map[string]interface{}{
|
||||
"session_token": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的请求体",
|
||||
@@ -39,8 +43,8 @@ func TestLogin(t *testing.T) {
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedBody: map[string]interface{}{
|
||||
"status": "error",
|
||||
"message": "Invalid request body: Username and password are required",
|
||||
"status": "error",
|
||||
"error": "Invalid request body",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -51,8 +55,8 @@ func TestLogin(t *testing.T) {
|
||||
},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedBody: map[string]interface{}{
|
||||
"status": "error",
|
||||
"message": "Invalid credentials",
|
||||
"status": "error",
|
||||
"error": "Invalid credentials",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -85,14 +89,7 @@ func TestLogin(t *testing.T) {
|
||||
// 断言响应体
|
||||
if tt.expectedStatus == http.StatusOK {
|
||||
// 对于成功的情况,我们只检查响应结构,不检查具体的 session token
|
||||
assert.Equal(t, "success", response["status"])
|
||||
assert.Equal(t, "", response["message"])
|
||||
data, ok := response["data"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
setCookie, ok := data["set-cookie"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, setCookie["session_token"])
|
||||
assert.Contains(t, strings.Join(w.Header().Values("Set-Cookie"), "\n"), "session_token=")
|
||||
assert.Contains(t, response, "set-cookie")
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedBody, response)
|
||||
}
|
||||
@@ -102,39 +99,3 @@ func TestLogin(t *testing.T) {
|
||||
accounts.DeleteAccountByUsername("testuser")
|
||||
accounts.DeleteAllSessions()
|
||||
}
|
||||
|
||||
func TestSessionCookieSecureFollowsRequestScheme(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
wantSecure bool
|
||||
}{
|
||||
{
|
||||
name: "http",
|
||||
requestURL: "http://example.test/login",
|
||||
},
|
||||
{
|
||||
name: "https",
|
||||
requestURL: "https://example.test/login",
|
||||
wantSecure: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, tt.requestURL, nil)
|
||||
|
||||
setSessionCookie(c, "test-session", sessionCookieMaxAge)
|
||||
|
||||
setCookie := strings.Join(w.Header().Values("Set-Cookie"), "\n")
|
||||
if tt.wantSecure {
|
||||
assert.Contains(t, setCookie, "Secure")
|
||||
} else {
|
||||
assert.NotContains(t, setCookie, "Secure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/internal/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 != ""})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/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()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
)
|
||||
|
||||
func GetNodesInformation(c *gin.Context) {
|
||||
clientList, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
resp.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]
|
||||
|
||||
resp.RespondSuccess(c, clientList)
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
package public
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/internal/oauth"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
OAuthEnabled, _ := config.GetAs[bool](config.OAuthEnabledKey, false)
|
||||
if !OAuthEnabled {
|
||||
cfg, _ := conf.GetWithV1Format()
|
||||
if !cfg.OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
@@ -86,7 +88,12 @@ func OAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
|
||||
event.Trigger(eventType.UserOidcBound, event.M{
|
||||
"user": user.UUID,
|
||||
"sso_id": sso_id,
|
||||
})
|
||||
c.Redirect(302, "/admin")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,18 +104,50 @@ func OAuthCallback(c *gin.Context) {
|
||||
"status": "error",
|
||||
"message": "please log in and bind your external account first.",
|
||||
})
|
||||
event.Trigger(eventType.UserLogin, event.M{
|
||||
"username": user.Username,
|
||||
"method": "oauth",
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
"error": "no linked account",
|
||||
"sso_id": sso_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
session, err := accounts.CreateSession(user.UUID, sessionCookieMaxAge, c.Request.UserAgent(), c.ClientIP(), "oauth")
|
||||
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
|
||||
event.Trigger(eventType.LoginFailed, event.M{
|
||||
"username": user.Username,
|
||||
"method": "oauth",
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
"error": err.Error(),
|
||||
"sso_id": sso_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置cookie并返回
|
||||
setSessionCookie(c, session, sessionCookieMaxAge)
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
event.Trigger(eventType.UserLogin, event.M{
|
||||
"username": user.Username,
|
||||
"method": "oauth",
|
||||
"ip": c.ClientIP(),
|
||||
"ua": c.Request.UserAgent(),
|
||||
"header": c.Request.Header,
|
||||
"referrer": c.Request.Referer(),
|
||||
"host": c.Request.Host,
|
||||
})
|
||||
c.Redirect(302, "/admin")
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
p, e := database.GetPublicInfo()
|
||||
if e != nil {
|
||||
resp.RespondError(c, 500, e.Error())
|
||||
return
|
||||
}
|
||||
resp.RespondSuccess(c, p)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
records "github.com/komari-monitor/komari/internal/database/records"
|
||||
"github.com/komari-monitor/komari/internal/database/tasks"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
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] {
|
||||
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hours := c.Query("hours")
|
||||
if uuid == "" {
|
||||
resp.RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
if hours == "" {
|
||||
hours = "4"
|
||||
}
|
||||
|
||||
hoursInt, err := strconv.Atoi(hours)
|
||||
if err != nil {
|
||||
resp.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] {
|
||||
resp.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 {
|
||||
resp.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
|
||||
}
|
||||
}
|
||||
|
||||
resp.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 == "" {
|
||||
resp.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] {
|
||||
resp.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 {
|
||||
resp.RespondError(c, 400, "Invalid task_id parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 查询记录,现在支持 uuid + task_id 组合查询
|
||||
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
|
||||
if err != nil {
|
||||
resp.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 {
|
||||
resp.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) // 计算最后结果保持计数一致
|
||||
resp.RespondSuccess(c, response)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package api
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
@@ -31,3 +32,9 @@ func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
func RespondError(c *gin.Context, httpStatus int, message string) {
|
||||
Respond(c, httpStatus, "error", message, nil)
|
||||
}
|
||||
func GetVersion(c *gin.Context) {
|
||||
RespondSuccess(c, gin.H{
|
||||
"version": conf.Version,
|
||||
"hash": conf.CommitHash,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/internal/api_v1/resp"
|
||||
"github.com/komari-monitor/komari/internal/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 {
|
||||
resp.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,
|
||||
}
|
||||
}
|
||||
|
||||
resp.RespondSuccess(c, publicTasks)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
clients "github.com/komari-monitor/komari/internal/client"
|
||||
"github.com/komari-monitor/komari/internal/database/auditlog"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
)
|
||||
|
||||
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 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()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package vars
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
var (
|
||||
Records = cache.New(1*time.Minute, 1*time.Minute)
|
||||
)
|
||||
@@ -1,27 +1,38 @@
|
||||
package api
|
||||
package api_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/protocol/v1"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/conf"
|
||||
"github.com/komari-monitor/komari/internal/database/accounts"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/ws"
|
||||
)
|
||||
|
||||
func GetClients(c *gin.Context) {
|
||||
// 升级到ws
|
||||
if !IsWebSocketUpgrade(c) {
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
cfg, _ := conf.GetWithV1Format()
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := UpgradeWebSocket(c)
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
@@ -52,12 +63,12 @@ func GetClients(c *gin.Context) {
|
||||
// 请求
|
||||
for {
|
||||
var resp struct {
|
||||
Online []string `json:"online"` // 已建立连接的客户端uuid列表
|
||||
Data map[string]v1.Report `json:"data"` // 最后上报的数据
|
||||
Online []string `json:"online"` // 已建立连接的客户端uuid列表
|
||||
Data map[string]common.Report `json:"data"` // 最后上报的数据
|
||||
}
|
||||
|
||||
resp.Online = []string{}
|
||||
resp.Data = map[string]v1.Report{}
|
||||
resp.Data = map[string]common.Report{}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -77,7 +88,7 @@ func GetClients(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 在线客户端uuid列表(WebSocket 与非 WebSocket)
|
||||
for _, key := range agent_runtime.GetAllOnlineUUIDs() {
|
||||
for _, key := range ws.GetAllOnlineUUIDs() {
|
||||
if !isLogin && hiddenMap[key] {
|
||||
continue
|
||||
}
|
||||
@@ -88,7 +99,7 @@ func GetClients(c *gin.Context) {
|
||||
}
|
||||
|
||||
//过往节点数据信息
|
||||
for key, report := range agent_runtime.GetLatestReport() {
|
||||
for key, report := range ws.GetLatestReport() {
|
||||
if !isLogin && hiddenMap[key] {
|
||||
continue
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Hooks struct {
|
||||
Init any
|
||||
Start any
|
||||
Stop any
|
||||
}
|
||||
|
||||
// Module is the single, explicit lifecycle unit.
|
||||
//
|
||||
// - Provide registers DI providers (optional; return nil if none).
|
||||
// - Hooks returns optional hook functions; dependencies are expressed as
|
||||
// parameters and injected by the container.
|
||||
//
|
||||
// Hook function shapes:
|
||||
//
|
||||
// func(context.Context, ...deps) error
|
||||
// func(...deps) error
|
||||
// func(context.Context, ...deps)
|
||||
// func(...deps)
|
||||
type Module interface {
|
||||
Name() string
|
||||
Depends() []string
|
||||
Provide(r Registry) error
|
||||
Hooks() Hooks
|
||||
}
|
||||
|
||||
type App struct {
|
||||
container *container
|
||||
shutdownTimeout time.Duration
|
||||
started bool
|
||||
regErr error
|
||||
modules map[string]Module
|
||||
moduleList []Module
|
||||
order []string
|
||||
startedOrder []string
|
||||
}
|
||||
|
||||
func New() *App {
|
||||
return &App{container: newContainer("root"), shutdownTimeout: 5 * time.Second, modules: make(map[string]Module)}
|
||||
}
|
||||
|
||||
func (a *App) With(mod Module) *App {
|
||||
if a.regErr != nil {
|
||||
return a
|
||||
}
|
||||
if a.container == nil {
|
||||
a.container = newContainer("root")
|
||||
}
|
||||
a.regErr = a.register(mod)
|
||||
if a.regErr == nil {
|
||||
a.moduleList = append(a.moduleList, mod)
|
||||
a.ensureDeps(mod, map[string]bool{})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *App) WithShutdownTimeout(d time.Duration) *App {
|
||||
if d > 0 {
|
||||
a.shutdownTimeout = d
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *App) Start(ctx context.Context) error {
|
||||
if a.regErr != nil {
|
||||
return a.regErr
|
||||
}
|
||||
if a.container == nil {
|
||||
a.container = newContainer("root")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = withContainerInContext(ctx, a.container)
|
||||
|
||||
// Register providers before hooks.
|
||||
for _, mod := range a.moduleList {
|
||||
if err := mod.Provide(a.container); err != nil {
|
||||
return fmt.Errorf("provide module %s: %w", mod.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
order, err := a.resolveOrder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Init hooks.
|
||||
for _, name := range order {
|
||||
h := a.modules[name].Hooks()
|
||||
if h.Init == nil {
|
||||
continue
|
||||
}
|
||||
if err := a.container.Invoke(ctx, h.Init); err != nil {
|
||||
return fmt.Errorf("init module %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start hooks.
|
||||
a.startedOrder = a.startedOrder[:0]
|
||||
for _, name := range order {
|
||||
h := a.modules[name].Hooks()
|
||||
if h.Start == nil {
|
||||
a.startedOrder = append(a.startedOrder, name)
|
||||
continue
|
||||
}
|
||||
if err := a.container.Invoke(ctx, h.Start); err != nil {
|
||||
_ = a.stopHooks(ctx)
|
||||
return fmt.Errorf("start module %s: %w", name, err)
|
||||
}
|
||||
a.startedOrder = append(a.startedOrder, name)
|
||||
}
|
||||
a.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Stop(ctx context.Context) error {
|
||||
if !a.started {
|
||||
return nil
|
||||
}
|
||||
stopCtx := ctx
|
||||
cancel := func() {}
|
||||
if stopCtx == nil {
|
||||
stopCtx, cancel = context.WithTimeout(context.Background(), a.shutdownTimeout)
|
||||
} else {
|
||||
stopCtx, cancel = context.WithTimeout(stopCtx, a.shutdownTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
if stopCtx == nil {
|
||||
stopCtx = context.Background()
|
||||
}
|
||||
stopCtx = withContainerInContext(stopCtx, a.container)
|
||||
err := a.stopHooks(stopCtx)
|
||||
_ = a.container.Shutdown(stopCtx)
|
||||
a.started = false
|
||||
return err
|
||||
}
|
||||
|
||||
// RunWith starts the app, executes fn, then stops the app.
|
||||
func (a *App) RunWith(ctx context.Context, fn func(context.Context) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = withContainerInContext(ctx, a.container)
|
||||
if err := a.Start(ctx); err != nil {
|
||||
_ = a.Stop(context.Background())
|
||||
return err
|
||||
}
|
||||
defer func() { _ = a.Stop(context.Background()) }()
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
// RunUntilSignal starts the app and blocks until ctx cancellation or SIGINT/SIGTERM.
|
||||
func (a *App) RunUntilSignal(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx = withContainerInContext(ctx, a.container)
|
||||
if err := a.Start(ctx); err != nil {
|
||||
_ = a.Stop(context.Background())
|
||||
return err
|
||||
}
|
||||
defer func() { _ = a.Stop(context.Background()) }()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
defer signal.Stop(quit)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-quit:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) register(mod Module) error {
|
||||
if mod == nil {
|
||||
return fmt.Errorf("module is nil")
|
||||
}
|
||||
name := mod.Name()
|
||||
if name == "" {
|
||||
return fmt.Errorf("module name is empty")
|
||||
}
|
||||
if _, exists := a.modules[name]; exists {
|
||||
return fmt.Errorf("duplicate module name: %s", name)
|
||||
}
|
||||
a.modules[name] = mod
|
||||
a.order = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) has(name string) bool {
|
||||
_, ok := a.modules[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (a *App) resolveOrder() ([]string, error) {
|
||||
if a.order != nil {
|
||||
return a.order, nil
|
||||
}
|
||||
|
||||
inDegree := make(map[string]int, len(a.modules))
|
||||
deps := make(map[string][]string, len(a.modules))
|
||||
reverse := make(map[string][]string, len(a.modules))
|
||||
|
||||
for name, mod := range a.modules {
|
||||
inDegree[name] = 0
|
||||
deps[name] = append([]string(nil), mod.Depends()...)
|
||||
}
|
||||
|
||||
for name := range a.modules {
|
||||
for _, dep := range deps[name] {
|
||||
if _, ok := a.modules[dep]; !ok {
|
||||
return nil, fmt.Errorf("module %s depends on missing module %s", name, dep)
|
||||
}
|
||||
inDegree[name]++
|
||||
reverse[dep] = append(reverse[dep], name)
|
||||
}
|
||||
}
|
||||
|
||||
q := make([]string, 0, len(a.modules))
|
||||
for name, d := range inDegree {
|
||||
if d == 0 {
|
||||
q = append(q, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(q)
|
||||
|
||||
order := make([]string, 0, len(a.modules))
|
||||
for len(q) > 0 {
|
||||
n := q[0]
|
||||
q = q[1:]
|
||||
order = append(order, n)
|
||||
for _, child := range reverse[n] {
|
||||
inDegree[child]--
|
||||
if inDegree[child] == 0 {
|
||||
q = append(q, child)
|
||||
}
|
||||
}
|
||||
sort.Strings(q)
|
||||
}
|
||||
|
||||
if len(order) != len(a.modules) {
|
||||
remaining := make([]string, 0)
|
||||
for name, d := range inDegree {
|
||||
if d != 0 {
|
||||
remaining = append(remaining, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(remaining)
|
||||
return nil, fmt.Errorf("dependency cycle detected among: %v", remaining)
|
||||
}
|
||||
|
||||
a.order = order
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (a *App) stopHooks(ctx context.Context) error {
|
||||
var firstErr error
|
||||
for i := len(a.startedOrder) - 1; i >= 0; i-- {
|
||||
name := a.startedOrder[i]
|
||||
h := a.modules[name].Hooks()
|
||||
if h.Stop == nil {
|
||||
continue
|
||||
}
|
||||
if err := a.container.Invoke(ctx, h.Stop); err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("stop module %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
a.startedOrder = a.startedOrder[:0]
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type containerCtxKey struct{}
|
||||
|
||||
func withContainerInContext(ctx context.Context, c *container) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if c == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, containerCtxKey{}, c)
|
||||
}
|
||||
|
||||
func containerFromContext(ctx context.Context) (*container, bool) {
|
||||
if ctx == nil {
|
||||
return nil, false
|
||||
}
|
||||
c, ok := ctx.Value(containerCtxKey{}).(*container)
|
||||
return c, ok && c != nil
|
||||
}
|
||||
|
||||
// Resolve is an explicit escape hatch for the rare cases where passing
|
||||
// dependencies via hook parameters is not ergonomic.
|
||||
//
|
||||
// Prefer declaring dependencies on hook function parameters instead.
|
||||
func Resolve[T any](ctx context.Context) (T, error) {
|
||||
var zero T
|
||||
c, ok := containerFromContext(ctx)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("no container in context")
|
||||
}
|
||||
|
||||
t := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v, err := c.resolve(ctx, key{t: t})
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return v.Interface().(T), nil
|
||||
}
|
||||
|
||||
// ResolveNamed resolves a named registration from ctx.
|
||||
func ResolveNamed[T any](ctx context.Context, name string) (T, error) {
|
||||
var zero T
|
||||
c, ok := containerFromContext(ctx)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("no container in context")
|
||||
}
|
||||
|
||||
t := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v, err := c.resolve(ctx, key{t: t, name: name})
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return v.Interface().(T), nil
|
||||
}
|
||||
|
||||
// Registry is the minimal surface a module needs to register providers.
|
||||
//
|
||||
// It intentionally does NOT expose resolution, so modules can't turn DI into a
|
||||
// service locator.
|
||||
type Registry interface {
|
||||
Provide(fn any, opts ...ProvideOption) error
|
||||
}
|
||||
|
||||
type key struct {
|
||||
t reflect.Type
|
||||
name string
|
||||
}
|
||||
|
||||
// container is a lightweight DI container with optional named registrations and scopes.
|
||||
//
|
||||
// It supports:
|
||||
// - Singleton instances per-scope.
|
||||
// - Providers declared as functions with injectable parameters.
|
||||
// - Scopes for plugin hot-plug (child container inherits parent providers/values).
|
||||
// - Optional shutdown hooks for providers (for scope disposal).
|
||||
//
|
||||
// Provider function shapes:
|
||||
// - func(...) T
|
||||
// - func(...) (T, error)
|
||||
// - func(...) (T, func(context.Context) error)
|
||||
// - func(...) (T, func(context.Context) error, error)
|
||||
//
|
||||
// Injectable parameters:
|
||||
// - context.Context: passed through from Invoke.
|
||||
// - any provided type (resolved recursively).
|
||||
//
|
||||
// Notes:
|
||||
// - For multiple implementations, use Named registrations.
|
||||
// - Cycles are detected during resolution.
|
||||
type container struct {
|
||||
name string
|
||||
parent *container
|
||||
|
||||
mu sync.Mutex
|
||||
values map[key]reflect.Value
|
||||
providers map[key]provider
|
||||
resolving map[key]bool
|
||||
closers []func(context.Context) error
|
||||
closed bool
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
fn reflect.Value
|
||||
outKey key
|
||||
hasErr bool
|
||||
hasCloser bool
|
||||
closerIndex int
|
||||
}
|
||||
|
||||
func newContainer(name string) *container {
|
||||
return &container{
|
||||
name: name,
|
||||
values: make(map[key]reflect.Value),
|
||||
providers: make(map[key]provider),
|
||||
resolving: make(map[key]bool),
|
||||
}
|
||||
}
|
||||
|
||||
type ProvideOption func(*provideOptions)
|
||||
|
||||
type provideOptions struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func Named(name string) ProvideOption {
|
||||
return func(o *provideOptions) { o.name = name }
|
||||
}
|
||||
|
||||
func (c *container) Provide(fn any, opts ...ProvideOption) error {
|
||||
if fn == nil {
|
||||
return fmt.Errorf("provider is nil")
|
||||
}
|
||||
var o provideOptions
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&o)
|
||||
}
|
||||
}
|
||||
|
||||
v := reflect.ValueOf(fn)
|
||||
t := v.Type()
|
||||
if t.Kind() != reflect.Func {
|
||||
return fmt.Errorf("provider must be a func, got %s", t.Kind())
|
||||
}
|
||||
if t.NumOut() < 1 || t.NumOut() > 3 {
|
||||
return fmt.Errorf("provider must return 1..3 values, got %d", t.NumOut())
|
||||
}
|
||||
|
||||
outT := t.Out(0)
|
||||
p := provider{fn: v, outKey: key{t: outT, name: o.name}, closerIndex: -1}
|
||||
|
||||
switch t.NumOut() {
|
||||
case 1:
|
||||
// T
|
||||
case 2:
|
||||
// (T, error) or (T, closer)
|
||||
if isErrorType(t.Out(1)) {
|
||||
p.hasErr = true
|
||||
} else if isCloserType(t.Out(1)) {
|
||||
p.hasCloser = true
|
||||
p.closerIndex = 1
|
||||
} else {
|
||||
return fmt.Errorf("provider second return must be error or func(context.Context) error")
|
||||
}
|
||||
case 3:
|
||||
// (T, closer, error)
|
||||
if !isCloserType(t.Out(1)) || !isErrorType(t.Out(2)) {
|
||||
return fmt.Errorf("provider must return (T, func(context.Context) error, error)")
|
||||
}
|
||||
p.hasCloser = true
|
||||
p.closerIndex = 1
|
||||
p.hasErr = true
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return fmt.Errorf("container %s is closed", c.name)
|
||||
}
|
||||
if _, exists := c.providers[p.outKey]; exists {
|
||||
return fmt.Errorf("duplicate provider for %s (name=%q)", outT, o.name)
|
||||
}
|
||||
c.providers[p.outKey] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) Invoke(ctx context.Context, fn any) error {
|
||||
if fn == nil {
|
||||
return fmt.Errorf("invoke fn is nil")
|
||||
}
|
||||
v := reflect.ValueOf(fn)
|
||||
if v.Kind() != reflect.Func {
|
||||
return fmt.Errorf("invoke target must be func")
|
||||
}
|
||||
args, err := c.buildArgs(ctx, v.Type())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outs := v.Call(args)
|
||||
if len(outs) == 0 {
|
||||
return nil
|
||||
}
|
||||
last := outs[len(outs)-1]
|
||||
if isErrorType(last.Type()) && !last.IsNil() {
|
||||
return last.Interface().(error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *container) Shutdown(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
closers := append([]func(context.Context) error(nil), c.closers...)
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
if err := closers[i](ctx); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (c *container) resolve(ctx context.Context, k key) (reflect.Value, error) {
|
||||
// Fast path: check local cache.
|
||||
c.mu.Lock()
|
||||
if v, ok := c.values[k]; ok {
|
||||
c.mu.Unlock()
|
||||
return v, nil
|
||||
}
|
||||
if c.resolving[k] {
|
||||
c.mu.Unlock()
|
||||
return reflect.Value{}, fmt.Errorf("dependency cycle resolving %s (name=%q)", k.t, k.name)
|
||||
}
|
||||
p, ok := c.providers[k]
|
||||
if !ok {
|
||||
parent := c.parent
|
||||
c.mu.Unlock()
|
||||
if parent != nil {
|
||||
return parent.resolve(ctx, k)
|
||||
}
|
||||
return reflect.Value{}, fmt.Errorf("no provider for %s (name=%q)", k.t, k.name)
|
||||
}
|
||||
c.resolving[k] = true
|
||||
c.mu.Unlock()
|
||||
|
||||
// Build args and call provider.
|
||||
args, err := c.buildArgs(ctx, p.fn.Type())
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.resolving, k)
|
||||
c.mu.Unlock()
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
outs := p.fn.Call(args)
|
||||
|
||||
var outV reflect.Value
|
||||
var closerFn reflect.Value
|
||||
var errV reflect.Value
|
||||
|
||||
switch len(outs) {
|
||||
case 1:
|
||||
outV = outs[0]
|
||||
case 2:
|
||||
outV = outs[0]
|
||||
if p.hasErr {
|
||||
errV = outs[1]
|
||||
} else if p.hasCloser {
|
||||
closerFn = outs[1]
|
||||
}
|
||||
case 3:
|
||||
outV = outs[0]
|
||||
closerFn = outs[1]
|
||||
errV = outs[2]
|
||||
}
|
||||
|
||||
if p.hasErr && !errV.IsNil() {
|
||||
err := errV.Interface().(error)
|
||||
c.mu.Lock()
|
||||
delete(c.resolving, k)
|
||||
c.mu.Unlock()
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.resolving, k)
|
||||
// Another goroutine could have resolved it while we were building; keep singleton semantics.
|
||||
if existing, ok := c.values[k]; ok {
|
||||
c.mu.Unlock()
|
||||
return existing, nil
|
||||
}
|
||||
c.values[k] = outV
|
||||
if p.hasCloser {
|
||||
cf := closerFn
|
||||
if cf.IsValid() && !cf.IsNil() {
|
||||
c.closers = append(c.closers, func(ctx context.Context) error {
|
||||
outs := cf.Call([]reflect.Value{reflect.ValueOf(ctx)})
|
||||
if len(outs) == 1 && !outs[0].IsNil() {
|
||||
return outs[0].Interface().(error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return outV, nil
|
||||
}
|
||||
|
||||
func (c *container) buildArgs(ctx context.Context, fnType reflect.Type) ([]reflect.Value, error) {
|
||||
in := fnType.NumIn()
|
||||
args := make([]reflect.Value, 0, in)
|
||||
|
||||
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
|
||||
|
||||
for i := 0; i < in; i++ {
|
||||
paramT := fnType.In(i)
|
||||
switch {
|
||||
case paramT == ctxType:
|
||||
args = append(args, reflect.ValueOf(ctx))
|
||||
default:
|
||||
v, err := c.resolve(ctx, key{t: paramT})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, v)
|
||||
}
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func isErrorType(t reflect.Type) bool {
|
||||
errT := reflect.TypeOf((*error)(nil)).Elem()
|
||||
return t == errT
|
||||
}
|
||||
|
||||
func isCloserType(t reflect.Type) bool {
|
||||
closerT := reflect.TypeOf((func(context.Context) error)(nil))
|
||||
return t == closerT
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
factoryMu sync.RWMutex
|
||||
factories = make(map[string]func() Module)
|
||||
)
|
||||
|
||||
// RegisterModuleFactory registers a factory used by App to auto-materialize missing dependencies.
|
||||
// Intended for core modules (config/db) and plugin-provided modules.
|
||||
func RegisterModuleFactory(name string, factory func() Module) {
|
||||
if name == "" || factory == nil {
|
||||
return
|
||||
}
|
||||
factoryMu.Lock()
|
||||
factories[name] = factory
|
||||
factoryMu.Unlock()
|
||||
}
|
||||
|
||||
func getModuleFactory(name string) (func() Module, bool) {
|
||||
factoryMu.RLock()
|
||||
f, ok := factories[name]
|
||||
factoryMu.RUnlock()
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func (a *App) ensureDeps(mod Module, visiting map[string]bool) {
|
||||
if a.regErr != nil {
|
||||
return
|
||||
}
|
||||
if mod == nil {
|
||||
a.regErr = fmt.Errorf("module is nil")
|
||||
return
|
||||
}
|
||||
|
||||
name := mod.Name()
|
||||
if visiting[name] {
|
||||
a.regErr = fmt.Errorf("dependency cycle while materializing: %s", name)
|
||||
return
|
||||
}
|
||||
visiting[name] = true
|
||||
defer func() { visiting[name] = false }()
|
||||
|
||||
for _, dep := range mod.Depends() {
|
||||
if dep == "" {
|
||||
continue
|
||||
}
|
||||
if a.has(dep) {
|
||||
continue
|
||||
}
|
||||
f, ok := getModuleFactory(dep)
|
||||
if !ok {
|
||||
a.regErr = fmt.Errorf("missing module %q (no factory registered)", dep)
|
||||
return
|
||||
}
|
||||
depMod := f()
|
||||
if depMod == nil {
|
||||
a.regErr = fmt.Errorf("module factory for %q returned nil", dep)
|
||||
return
|
||||
}
|
||||
// Register dependency module and its dependencies recursively.
|
||||
a.regErr = a.register(depMod)
|
||||
if a.regErr != nil {
|
||||
return
|
||||
}
|
||||
a.moduleList = append(a.moduleList, depMod)
|
||||
a.ensureDeps(depMod, visiting)
|
||||
if a.regErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
"github.com/komari-monitor/komari/pkg/utils"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
c := models.Client{}
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUuid).First(&c).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event.Trigger(eventType.ClientDeleted, event.M{
|
||||
"client": c})
|
||||
return nil
|
||||
}
|
||||
|
||||
func PartialUpdate(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
if !ok || clientUUID == "" {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(update) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
c := models.Client{}
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&c).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
checkInt64 := func(name string, val float64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
|
||||
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
|
||||
}
|
||||
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verify(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
new_c := models.Client{}
|
||||
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&new_c).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event.Trigger(eventType.ClientUpdated, event.M{
|
||||
"old": c,
|
||||
"new": new_c,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateClient 创建新客户端
|
||||
func CreateClient() (clientUUID, token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
token = utils.GenerateToken()
|
||||
clientUUID = uuid.New().String()
|
||||
|
||||
client := models.Client{
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: "client_" + clientUUID[0:8],
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
event.Trigger(eventType.ClientCreated, event.M{
|
||||
"client": client,
|
||||
})
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
if name == "" {
|
||||
return CreateClient()
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
token = utils.GenerateToken()
|
||||
clientUUID = uuid.New().String()
|
||||
client := models.Client{
|
||||
UUID: clientUUID,
|
||||
Token: token,
|
||||
Name: name,
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
event.Trigger(eventType.ClientCreated, event.M{
|
||||
"client": client,
|
||||
})
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.Token, nil
|
||||
}
|
||||
|
||||
func GetAllClientBasicInfo() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/common"
|
||||
"github.com/komari-monitor/komari/internal/database/models"
|
||||
"github.com/komari-monitor/komari/internal/dbcore"
|
||||
)
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
err = db.Where("token = ?", token).First(&client).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report common.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
|
||||
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
|
||||
}
|
||||
|
||||
checkFloat64 := func(name string, val float64) error {
|
||||
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
|
||||
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [float64] 防止数据溢出
|
||||
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkInt64 := func(name string, val int64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, val)
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("process must be non-negative: %d", report.Process)
|
||||
}
|
||||
if report.Connections.TCP < 0 {
|
||||
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
|
||||
}
|
||||
if report.Connections.UDP < 0 {
|
||||
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// // SaveClientReport 保存客户端报告到 Record 表
|
||||
// func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
// db := dbcore.GetDBInstance()
|
||||
|
||||
// if err := ReportVerify(report); err != nil {
|
||||
// return fmt.Errorf("failed to save Record: %v", err)
|
||||
// }
|
||||
|
||||
// // 保存GPU详细记录到独立表
|
||||
// currentTime := time.Now()
|
||||
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
// for idx, gpu := range report.GPU.DetailedInfo {
|
||||
// gpuRecord := models.GPURecord{
|
||||
// Client: clientUUID,
|
||||
// Time: models.FromTime(currentTime),
|
||||
// DeviceIndex: idx,
|
||||
// DeviceName: gpu.Name,
|
||||
// MemTotal: gpu.MemoryTotal,
|
||||
// MemUsed: gpu.MemoryUsed,
|
||||
// Utilization: float32(gpu.Utilization),
|
||||
// Temperature: gpu.Temperature,
|
||||
// }
|
||||
// if err := db.Create(&gpuRecord).Error; err != nil {
|
||||
// return fmt.Errorf("failed to save GPU record: %v", err)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 计算平均GPU使用率,用于向后兼容
|
||||
// averageGPUUsage := float32(0)
|
||||
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
// averageGPUUsage = float32(report.GPU.AverageUsage)
|
||||
// }
|
||||
|
||||
// Record := models.Record{
|
||||
// Client: clientUUID,
|
||||
// Time: 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
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,149 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func downloadCloudflared(downloadURL, filePath string) error {
|
||||
log.Println("downloading cloudflared...")
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
closeErr := out.Close() // 先关闭文件
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
err = os.Chmod(filePath, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var cloudflaredCmd *exec.Cmd
|
||||
|
||||
func RunCloudflared() error {
|
||||
var (
|
||||
downloadURL string
|
||||
fileName = "cloudflared"
|
||||
)
|
||||
if _, err := os.Stat("data"); os.IsNotExist(err) {
|
||||
os.Mkdir("data", 0755)
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
fileName = "cloudflared.exe"
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
|
||||
case "386":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
|
||||
case "386":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386"
|
||||
case "arm":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm"
|
||||
case "arm64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
|
||||
case "arm64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported os: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
filePath := "data/" + fileName
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
err := downloadCloudflared(downloadURL, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"tunnel", "--no-autoupdate", "run", "--token"}
|
||||
token := os.Getenv("KOMARI_CLOUDFLARED_TOKEN")
|
||||
args = append(args, token)
|
||||
|
||||
cmd := exec.Command(filePath, args...)
|
||||
cloudflaredCmd = cmd
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
log.Printf("[cloudflared] %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
log.Printf("[cloudflared] %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("cloudflared exited with error: %v", err)
|
||||
} else {
|
||||
log.Println("cloudflared exited successfully")
|
||||
}
|
||||
os.Exit(1)
|
||||
}()
|
||||
log.Println("cloudflared started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func Kill() {
|
||||
if cloudflaredCmd != nil && cloudflaredCmd.Process != nil {
|
||||
err := cloudflaredCmd.Process.Kill()
|
||||
if err != nil {
|
||||
log.Printf("failed to kill cloudflared: %v", err)
|
||||
} else {
|
||||
log.Println("cloudflared killed")
|
||||
}
|
||||
cloudflaredCmd = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cloudflared_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/cloudflared"
|
||||
)
|
||||
|
||||
func TestRunCloudflared(t *testing.T) {
|
||||
os.Setenv("KOMARI_CLOUDFLARED_TOKEN", "test-token")
|
||||
|
||||
err := cloudflared.RunCloudflared()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCloudflared failed: %v", err)
|
||||
}
|
||||
|
||||
// 等待一段时间,确保子进程已启动
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gookit/event"
|
||||
"github.com/komari-monitor/komari/internal/eventType"
|
||||
)
|
||||
|
||||
func init() {
|
||||
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
|
||||
if strings.ToLower(strings.ToLower(os.Getenv("KOMARI_ENABLE_CLOUDFLARED"))) == "true" {
|
||||
err := RunCloudflared()
|
||||
if err != nil {
|
||||
// Error in ServerInitializeStart will cause the process to exit
|
||||
return fmt.Errorf("failed to run cloudflared: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
event.On(eventType.ProcessExit, event.ListenerFunc(func(e event.Event) error {
|
||||
Kill()
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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"`
|
||||
}
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user