Compare commits

..
Author SHA1 Message Date
Akizon 8165440839 Merge pull request #431 from komari-monitor/dev
refactor: 生命周期管理
2026-01-22 03:15:34 +08:00
Akizon77 356f6903a3 refactor: 使用生命周期管理
- Introduced a new `app` package to manage application lifecycle and dependency injection.
- Created `config` and `db` modules to handle configuration loading and database initialization.
- Removed legacy event-based configuration loading in favor of module initialization.
- Implemented hooks for module lifecycle events (Init, Start, Stop).
- Added a scheduler module for periodic tasks and event triggering.
- Updated server module to integrate with the new lifecycle system and handle HTTP server startup/shutdown.
- Ensured backward compatibility with existing configuration and database handling.
2026-01-22 03:01:27 +08:00
Akizon f693eaf764 Merge pull request #410 from RSJWY/dev
支持HarmonyOS系统的MeoW通知应用(统一使用华为Push)
2026-01-15 22:58:04 +08:00
Akizon 35b49222c0 Merge pull request #426 from komari-monitor/dev
wip: 插件支持
2026-01-15 22:56:37 +08:00
Akizon77 8b832e7ce5 chore: 注释掉dev分支的push触发器 2026-01-15 22:49:54 +08:00
Akizon77 fca23546f1 feat: js监听事件/发布事件 2026-01-15 22:45:40 +08:00
Akizon77 e187116d7a feat: JS运行支持async、实现Fetch和XHR 2026-01-15 21:43:08 +08:00
Akizon77 9af4cbb3f9 撤回首次加载引导(之后再做) 2026-01-15 20:19:29 +08:00
RSJWY 2a3e458f63 支持HarmonyOS系统的MeoW通知应用(统一使用华为Push) 2025-12-31 22:37:40 +08:00
Akizon ca350be7a5 Merge pull request #404 from flyflas/add-email-html-support
Add html support for email
2025-12-31 10:22:22 +08:00
xiaobai 0b8f2fc176 add html support for email 2025-12-23 18:57:52 +08:00
Akizon77 c2d8008abc wip: 添加插件管理功能,支持插件激活和清单获取 2025-12-12 00:05:21 +08:00
Akizon77 56b14320e8 refactor: 迁移client 2025-12-11 13:39:53 +08:00
Akizon77 76f6442fbe fix: CORS配置失效 2025-12-11 00:52:15 +08:00
Akizon77 446311f343 refactor: 将时间处理逻辑统一为 UTC 2025-12-10 21:11:53 +08:00
Akizon77 475b2352db feat: 添加监听地址配置,重构数据库迁移逻辑,优化模型字段定义 2025-12-10 20:43:28 +08:00
Akizon77 ebb024a361 refactor: 重构数据库核心模块 2025-12-08 18:22:02 +08:00
Akizon77 6c608b22f6 refactor: 移除过时的数据库配置,整合配置管理 2025-12-08 18:10:48 +08:00
Akizon77 c70dae1396 feat: 添加扩展配置支持,重构 Nezha 兼容字段 2025-12-08 17:21:58 +08:00
Akizon77 b980857d3e refactor: routers.go -> server.go 2025-12-08 15:57:45 +08:00
github-actions[bot] 2b8280cc48 Merge pull request #387 from komari-monitor/dev
feat: mjpeg图片推流、rpc内部调用
2025-12-07 10:27:48 +00:00
Akizon77 510d672f24 fix: 修复配置文件写入逻辑 2025-12-06 17:30:31 +08:00
Akizon77 ea53facb98 fix: 修复配置文件和数据库文件的存在性检查逻辑 2025-12-06 17:24:06 +08:00
Akizon77 a7a6a7b410 fix: v1.1.4配置表迁移功能 2025-12-06 17:20:39 +08:00
Akizon77 bf9ad3357e feat: mjpeg图片推流、rpc内部调用 2025-12-06 17:08:09 +08:00
Akizon77 48c6030393 wip: 添加插件、mjpeg流相关 2025-12-06 14:43:25 +08:00
Akizon 026b12e8ae Merge pull request #378 from komari-monitor/dev
refactor: 事件路由
2025-12-02 02:12:24 +08:00
Akizon77 90aad4b48a refactor: API initialization and routing
- Moved API route registration to dedicated init files for better organization.
- Introduced event listeners for server initialization to dynamically register routes.
- Removed redundant configuration loading in routers.go.
- Added new API routes for various functionalities including client management, admin tasks, and notifications.
- Implemented a standardized response structure for API responses.
- Established WebSocket connections for terminal sessions and improved session management.
- Created a new database initialization for default admin account creation.
- Enhanced gRPC server setup for Nezha compatibility with dynamic configuration updates.
2025-12-02 01:57:08 +08:00
Akizon77 84ec0a9d87 fix: oauth 初始化意外调用未初始化的 dbcore 2025-12-01 23:21:40 +08:00
Akizon77 801d9415ed refactor: 重构服务器启动、路由加载的初始化 2025-12-01 23:13:44 +08:00
Akizon77 f8b4e90d47 refactor: 移除不必要的依赖和初始化逻辑,简化配置更新处理 2025-12-01 21:51:14 +08:00
Akizon77 5b5cf32a28 refactor: 优化配置更新和定时任务管理 2025-12-01 21:20:22 +08:00
github-actions[bot] 0413c9aa80 Merge pull request #370 from komari-monitor/dev
refactor: 引入事件驱动
2025-11-25 07:07:10 +00:00
Akizon77 1672bac3de refactor: 优化权限检测逻辑,简化配置引用 2025-11-25 15:05:49 +08:00
Akizon77 24322b613d refactor: 添加事件触发逻辑以增强客户端和用户操作的跟踪 2025-11-21 15:31:01 +08:00
Akizon77 3729269fa3 refactor: 重写Config配置管理 2025-11-15 17:18:21 +08:00
Akizon77 0c8095ae11 refactor: 添加 RPC 路由注册 2025-11-15 13:22:45 +08:00
Akizon77 535acc99b8 refactor: api_v1/jsonRpc -> api_rpc 2025-11-15 13:14:55 +08:00
Akizon77 01912be70e refactor: 移除配置事件订阅 2025-11-14 23:42:46 +08:00
Akizon77 df62a87591 refactor: 优化 gRPC 服务器启动逻辑 2025-11-14 23:30:48 +08:00
Akizon77 a3faf856e0 refactor: 移动 PingSchedule 相关逻辑 2025-11-14 23:17:40 +08:00
Akizon77 39f849d03e wip: 引入事件驱动 2025-11-14 23:11:46 +08:00
Akizon77 53b3d7dd71 wip: 重构框架 2025-11-14 18:21:23 +08:00
Akizon eab4b513be Merge pull request #361 from komari-monitor/dev
feat: 添加 WAL 检查点优化数据库性能
2025-11-14 16:46:41 +08:00
583 changed files with 22034 additions and 77749 deletions

No files matched your search

-74
View File
@@ -1,74 +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
ref:
description: Optional komari-web ref to build. Uses the repository default branch when empty.
default: ""
artifact-name:
description: Optional artifact name for the packed frontend theme.
default: ""
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Install zstd
shell: bash
run: |
set -euxo pipefail
if ! command -v zstd >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y zstd
fi
- name: Clone and build frontend
shell: bash
run: |
set -euxo pipefail
rm -rf komari-web
if [ -n "${{ inputs.ref }}" ]; then
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
else
git clone --depth=1 "${{ inputs.repository }}" komari-web
fi
cd komari-web
npm install
npm run build
cd ..
mkdir -p web/public/defaultTheme
rm -rf web/public/defaultTheme/dist web/public/defaultTheme/dist.tar.zst
test -f komari-web/dist/index.html
tar -cf "$RUNNER_TEMP/komari-default-dist.tar" -C komari-web/dist .
zstd -19 -T0 -q -f "$RUNNER_TEMP/komari-default-dist.tar" -o web/public/defaultTheme/dist.tar.zst
rm -f "$RUNNER_TEMP/komari-default-dist.tar"
cp -f komari-web/komari-theme.json web/public/defaultTheme/
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Pack frontend artifact
if: ${{ inputs.artifact-name != '' }}
shell: bash
run: |
set -euxo pipefail
tar -czf "$RUNNER_TEMP/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme .
- name: Upload frontend artifact
if: ${{ inputs.artifact-name != '' }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/${{ inputs.artifact-name }}.tar.gz
@@ -1,25 +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
test -s web/public/defaultTheme/dist.tar.zst
-31
View File
@@ -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 -26
View File
@@ -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:
@@ -59,10 +49,6 @@ jobs:
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
@@ -72,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
@@ -81,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:
@@ -98,11 +101,7 @@ jobs:
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
if [ "${{ matrix.goos }}" = "linux" ]; then
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
fi
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
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
+17 -5
View File
@@ -1,9 +1,9 @@
name: Development Environment
on:
push:
branches:
- dev
# push:
# branches:
# - dev
workflow_dispatch:
jobs:
@@ -14,8 +14,20 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
#- name: Create dummy index.html
# run: |
# mkdir -p public/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
- 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
@@ -28,7 +40,7 @@ jobs:
GOARCH: amd64
run: |
VERSION_SHA="${{ github.sha }}"
go build -ldflags "-s -w -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
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
+40 -58
View File
@@ -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 -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Upload Docker binaries
uses: actions/upload-artifact@v4
with:
name: komari-docker-binaries
path: komari-linux-*
build-and-push-multi-arch:
needs: build-docker-binaries
runs-on: ubuntu-latest
permissions:
contents: read
@@ -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();
-163
View File
@@ -1,163 +0,0 @@
name: Rebuild Existing Release
on:
workflow_dispatch:
inputs:
release_tag:
description: Existing GitHub Release tag whose assets and version image will be replaced.
required: true
type: string
server_ref:
description: Server commit, branch, or tag to build.
required: true
type: string
frontend_ref:
description: komari-web commit, branch, or tag to build.
required: true
type: string
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout workflow source
uses: actions/checkout@v4
- name: Build selected frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ inputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Restore frontend artifact
uses: komari-monitor/komari/.github/actions/restore-frontend@main
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
uses: komari-monitor/komari/.github/actions/setup-zig@main
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
VERSION_HASH="$(git rev-parse HEAD)"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
if [ "${{ matrix.goos }}" = "linux" ]; then
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
fi
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
- name: Upload binary to release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
build-and-push-docker:
needs: build-and-attach-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push version Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
no-cache: true
provenance: false
+45 -65
View File
@@ -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 -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Upload Docker binaries
uses: actions/upload-artifact@v4
with:
name: komari-docker-binaries
path: komari-linux-*
build-and-push-multi-arch:
needs: build-docker-binaries
runs-on: ubuntu-latest
permissions:
contents: read
@@ -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,8 @@ 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
uses: docker/build-push-action@v5
+21 -119
View File
@@ -13,38 +13,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
resolve-release:
runs-on: ubuntu-latest
outputs:
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
steps:
- id: release
env:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
build-frontend:
needs: resolve-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
@@ -83,10 +52,6 @@ jobs:
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
@@ -96,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
@@ -105,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:
@@ -121,17 +103,7 @@ jobs:
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
if [ "${{ matrix.goos }}" = "linux" ]; then
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
fi
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
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:
@@ -145,73 +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: Check whether this is the latest GitHub release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: |
is_latest="$(gh api graphql \
-f 'query=query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { isLatest } } }' \
-F owner="${{ github.repository_owner }}" \
-F name="${{ github.event.repository.name }}" \
-F tag="$TAG" \
--jq '.data.repository.release.isLatest')"
echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
- name: Promote Docker latest tag
if: steps.release.outputs.is_latest == 'true'
env:
TAG: ${{ github.event.release.tag_name }}
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
-297
View File
@@ -1,297 +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: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
uses: ./.github/actions/setup-zig
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ needs.prepare.outputs.version }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
if [ "${{ matrix.goos }}" = "linux" ]; then
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
fi
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
publish-snapshot-release:
needs: [prepare, build-and-attach-binaries]
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure commit is still current main
run: |
git fetch --no-tags --depth=1 origin main
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
echo "Snapshot publish skipped because a newer main commit exists."
echo "Workflow SHA: ${GITHUB_SHA}"
echo "Current main: ${MAIN_SHA}"
exit 1
fi
- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create snapshot prerelease and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euxo pipefail
echo "Snapshot version: ${VERSION}"
ls -alR artifacts
# 如果同名 release/tag 已存在(例如重跑),先删除避免创建冲突
if gh release view "${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release ${VERSION} 已存在,正在删除以重新创建..."
gh release delete "${VERSION}" --repo "${{ github.repository }}" --cleanup-tag --yes
fi
# 创建预发布并上传所有二进制文件
gh release create "${VERSION}" \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "${VERSION}" \
--notes "Automated snapshot build (${VERSION}) from commit ${{ github.sha }}." \
--prerelease \
artifacts/komari-*
gh workflow run generate-release-notes.yml \
--repo "${{ github.repository }}" \
--ref main \
-f tag="${VERSION}"
gh release list \
--repo "${{ github.repository }}" \
--limit 1000 \
--json tagName,isPrerelease \
--jq '.[] | select(.isPrerelease and (.tagName | startswith("Snapshot-"))) | .tagName' |
while IFS= read -r tag; do
if [ -z "${tag}" ] || [ "${tag}" = "${VERSION}" ]; then
continue
fi
echo "Deleting old snapshot release ${tag}..."
gh release delete "${tag}" --repo "${{ github.repository }}" --cleanup-tag --yes
done
shell: bash
build-and-push-docker:
needs: [prepare, build-and-attach-binaries, publish-snapshot-release]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure commit is still current main
run: |
git fetch --no-tags --depth=1 origin main
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
echo "Snapshot Docker publish skipped because a newer main commit exists."
echo "Workflow SHA: ${GITHUB_SHA}"
echo "Current main: ${MAIN_SHA}"
exit 1
fi
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=snapshot
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+1 -6
View File
@@ -15,7 +15,6 @@ komari.db
/utils/geoip/data
.vscode/
/backup
/cache
# Test binary, built with `go test -c`
*.test
@@ -34,8 +33,4 @@ go.work.sum
.env
# build
komari
/dist/
.codex
.gocache/
/web/filemanager/data
komari
+12 -4
View File
@@ -6,14 +6,22 @@ WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl tzdata
RUN apk add --no-cache tzdata
COPY --chmod=755 komari-${TARGETOS}-${TARGETARCH} /app/komari
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
ENV KOMARI_DB_PORT=3306
ENV KOMARI_DB_USER=root
ENV KOMARI_DB_PASS=
ENV KOMARI_DB_NAME=komari
ENV KOMARI_LISTEN=0.0.0.0:25774
ENV GODEBUG=disablethp=1
EXPOSE 25774
CMD ["/app/komari", "server"]
CMD ["/app/komari", "server"]
+107 -41
View File
@@ -5,64 +5,130 @@
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
[English](./README.md) | [简体中文](./README_zh-cn.md)
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
Komari is a lightweight, self-hosted server monitoring solution. It provides a simple and efficient way to track server performance through a web interface, with metrics collected by a lightweight agent.
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 application. Deploy it only on systems you own or are authorized to manage. You are solely responsible for how you deploy and use Komari. The developers accept no liability for unauthorized access, persistence, command execution, other misuse, or any resulting consequences.
[Documentation](https://www.komari.wiki/) | [Telegram Group](https://t.me/komari_monitor)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
- **Real-time monitoring**: Displays monitoring data at one-second intervals.
- **Lightweight and efficient**: Uses minimal system resources and works well on servers of any size.
- **Self-hosted**: Keeps you in control of your data and privacy.
- **Web interface**: Provides an intuitive, easy-to-use monitoring dashboard.
- **Extensible**: Supports custom themes and plugins.
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
## Quick Start
| Platform | Description |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | Deploy websites, databases, and hundreds of popular apps in seconds with flexible hourly billing. [Get started for just ¥5/month. Deploy now!](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel App Store" width="180"></a> | A modern, open-source Linux server management panel for websites, databases, containers, files, backups, security, and AI, with one-click deployment from its app store. |
### 0. One-click Deployment with Cloud Hosting
For instructions on Docker deployment, binary installation, building from source, and updates, see the [installation guide](https://www.komari.wiki/en/install/quick-start).
- Rainyun - CNY 4.5/month
## Screenshots
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
| Page | Screenshot |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Home Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Home Dashboard"> |
| Admin Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Admin Dashboard"> |
| History Charts | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8-en.webp" width="800" alt="History Charts"> |
| Web Terminal | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="Web Terminal"> |
| Customizable Themes | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89-en.webp" width="800" alt="Customizable Themes"> |
| Theme Market | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA-en.webp" width="800" alt="Theme Market"> |
### 1. Use the One-click Install Script
## Sponsors
Suitable for distributions using systemd (Ubuntu, Debian...).
Interested in sponsoring Komari? Contact the developer via [email](mailto:komari@akz.moe) or [Telegram](https://t.me/mamomoe).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
| Sponsor | Description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · Rotating residential proxies from $0.20/GB. Sign up for 500MB of free traffic and try it today!](https://mooproxy.net/?utm_source=komari) |
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [Self-Hosted Private CDN \| Subscription-Based CDN-Like Service \| A Fully Controlled, Flexible, Modular CDN Network](https://axisnow.io/zh?utm=komari) |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | Cost-effective Asia-Pacific hosting with direct connectivity and robust DDoS protection, backed by transparent capacity claims. |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | Premium China-optimized connectivity from Asia-Pacific data centers, featuring low latency, high bandwidth, and Tbps-scale local DDoS mitigation. Join the [Telegram community](https://t.me/SharonNetwork) to participate in charitable initiatives and community giveaways. |
### 2. Docker Deployment
## Contributors
1. Create a data directory:
```bash
mkdir -p ./data
```
2. Run the Docker container:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. View the default username and password:
```bash
docker logs komari
```
4. Access `http://<your_server_ip>:25774` in your browser.
Thanks to everyone who has contributed code, themes, plugins, documentation, translations, bug reports, or feedback to Komari.
> [!NOTE]
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari contributors" width="600"></a>
### 3. Binary File Deployment
## Support the Project
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
If Komari has been useful to you, consider buying me a coffee. Thank you for your support!
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
| TRON Network |
| ------------------------------------------------------------------------------------------- |
| ![TRON Network QR code](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
### Manual Build
#### Dependencies
- Go 1.24+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. Build the backend:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```bash
go build -o komari
```
3. Run:
```bash
./komari server -l 0.0.0.0:25774
```
The default listening port is `25774`. Access `http://localhost:25774`.
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
## Contributing
Issues and Pull Requests are welcome!
## Acknowledgements
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供 Tbps 级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-68
View File
@@ -1,68 +0,0 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
[English](./README.md) | [简体中文](./README_zh-cn.md)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
> [!WARNING]
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为,用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
[文档](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
## 特性
- **实时监控**: 秒级实时数据展示。
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
- **极强的可扩展性**: 支持自定义主题和插件。
## 快速开始
| 平台 | 介绍 |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | 秒级部署网站、数据库及数百款热门 App,并采用按小时灵活计费。[每月5元,立即部署](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel Appstore" width="180"></a> | 现代化、开源的Linux 服务器运维管理面板,提供网站、数据库、容器、文件、备份、安全与AI 管理能力,支持应用商店一键部署。 |
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
## 截图
| 页面 | 截图 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 主页仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="主页仪表盘"> |
| 后台仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="后台仪表盘"> |
| 历史图表 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8.webp" width="800" alt="历史图表"> |
| 网页终端 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="网页终端"> |
| 主题可自定义 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89.webp" width="800" alt="主题可自定义"> |
| 主题市场 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA.webp" width="800" alt="主题市场"> |
## 赞助商
有意赞助 Komari?请通过 [电子邮箱](mailto:komari@akz.moe) 或 [Telegram](https://t.me/mamomoe) 联系开发者。
| 赞助商 | 描述 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · 动态住宅代理IP低至 $0.20/GB,注册即送 500MB 免费流量,立即体验!](https://mooproxy.net/?utm_source=komari) |
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [自建私有部署CDN \| 订阅式高仿CDN \| 自主可控、灵活组合的CDN网络](https://axisnow.io/zh?utm=komari) |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
## 贡献者
感谢所有为 Komari 贡献代码、主题、插件、文档、翻译、问题报告或反馈的朋友。
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari 贡献者" width="600"></a>
## 支持项目
如果 Komari 对你有所帮助,欢迎请作者喝一杯奶茶。感谢你的支持!
| TRON Network |
| --------------------------------------------------------------------------- |
| ![TRON](https://b2.akz.moe/awesome-pictures/PixPin_2026-08-07_15-16-52.png) |
+29 -21
View File
@@ -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
View File
@@ -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 -32
View File
@@ -1,36 +1,6 @@
package flags
import "strings"
const (
DatabaseTypeSQLite = "sqlite"
)
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
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
}
+404
View File
@@ -0,0 +1,404 @@
package cmd
import (
"context"
"errors"
"io"
"log"
"math"
"net"
"strings"
"sync"
"time"
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/internal/nezha/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"gorm.io/gorm/clause"
)
// [Deprecated] Use StartNezhaCompat instead.
func StartNezhaCompatServer(addr string) error {
boot := uint64(time.Now().Unix())
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
srv := &nezhaCompatServer{bootTime: boot}
// Interceptors for metadata auth presence (lightweight; validation is inside handlers too)
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
// Enable gRPC keepalive to tolerate long-idle streams
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
// Keepalive: allow client pings and keep connections stable without app traffic
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 2 * time.Minute,
Timeout: 20 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: true,
}),
)
proto.RegisterNezhaServiceServer(gs, srv)
log.Printf("Nezha compat gRPC listening on %s", addr)
return gs.Serve(lis)
}
// ---- Manual start/stop support ----
var (
nezhaSrv *grpc.Server
nezhaLis net.Listener
nezhaOnceM sync.Mutex
)
// StartNezhaCompat starts the Nezha compatible gRPC server asynchronously.
// Returns error if already started.
func StartNezhaCompat(addr string) error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv != nil {
return errors.New("nezha compat server already started")
}
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
boot := uint64(time.Now().Unix())
sImpl := &nezhaCompatServer{bootTime: boot}
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute, Timeout: 20 * time.Second}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
)
proto.RegisterNezhaServiceServer(gs, sImpl)
nezhaSrv = gs
nezhaLis = lis
go func() {
if err := gs.Serve(lis); err != nil {
log.Printf("Nezha compat gRPC server stopped: %v", err)
}
}()
log.Printf("Nezha compat gRPC started on %s", addr)
return nil
}
// StopNezhaCompat stops the server if running.
func StopNezhaCompat() error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv == nil {
return errors.New("nezha compat server not running")
}
// 强制立即断开所有连接与流,不等待在途 RPC 完成。
nezhaSrv.Stop()
// Listener close (Serve already returns after GracefulStop, but close to be explicit)
if nezhaLis != nil {
_ = nezhaLis.Close()
}
nezhaSrv = nil
nezhaLis = nil
log.Printf("Nezha compat gRPC stopped")
return nil
}
type nezhaCompatServer struct {
proto.UnimplementedNezhaServiceServer
bootTime uint64
}
// authentication helpers
func getAuth(ctx context.Context) (uuid string, secret string, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", "", errors.New("missing metadata")
}
getFirst := func(key string) string {
vals := md.Get(key)
if len(vals) > 0 {
return vals[0]
}
return ""
}
uuid = getFirst("client_uuid")
secret = getFirst("client_secret")
if uuid == "" || secret == "" {
return "", "", errors.New("unauthorized: missing client_uuid/client_secret")
}
return uuid, secret, nil
}
// ReportSystemInfo: upsert static host info
func (s *nezhaCompatServer) ReportSystemInfo(ctx context.Context, host *proto.Host) (*proto.Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Receipt{Proced: true}, nil
}
// ReportSystemInfo2: same as above but returns dashboard boot time
func (s *nezhaCompatServer) ReportSystemInfo2(ctx context.Context, host *proto.Host) (*proto.Uint64Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Uint64Receipt{Data: s.bootTime}, nil
}
// ReportSystemState: ingest time-series records
func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSystemStateServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
// presence start
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
go notifier.OnlineNotification(uuid, connID)
defer func() {
ws.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}()
for {
st, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// refresh presence TTL on every frame
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
if err := ingestState(uuid, st); err != nil {
// still ack to avoid client stuck; log error
log.Printf("Nezha ingest state error: %v", err)
}
if err := stream.Send(&proto.Receipt{Proced: true}); err != nil {
return err
}
}
}
// RequestTask: do not dispatch tasks, just drain results to avoid Unimplemented
func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
defer ws.SetPresence(uuid, connID, false)
// receive results in background
recvErr := make(chan error, 1)
go func() {
for {
_, rerr := stream.Recv()
if rerr == io.EOF {
recvErr <- nil
return
}
if rerr != nil {
recvErr <- rerr
return
}
// refresh presence TTL when result received
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
}
}()
// send heartbeat tasks periodically
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
return err
case <-ticker.C:
if err := stream.Send(&proto.Task{}); err != nil {
return err
}
}
}
}
// Unimplemented methods intentionally left as default (IOStream, ReportGeoIP)
// upsertClientFromHost maps Host into models.Client and upserts by UUID
func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
db := dbcore.GetDBInstance()
now := models.FromTime(time.Now())
// token guard: if existing record has different token, reject
var exist models.Client
if err := db.Where("uuid = ?", uuid).First(&exist).Error; err == nil {
if exist.Token != "" && exist.Token != secret {
return errors.New("unauthorized: token mismatch")
}
}
cpuName := ""
if len(h.Cpu) > 0 {
cpuName = h.Cpu[0]
}
gpuName := strings.Join(h.Gpu, "; ")
osName := h.Platform
if h.PlatformVersion != "" {
osName = h.Platform + " " + h.PlatformVersion
}
// clamp uint64 to int64 safely
clamp := func(v uint64) int64 {
if v > uint64(math.MaxInt64) {
return math.MaxInt64
}
return int64(v)
}
c := models.Client{
UUID: uuid,
Token: secret,
Name: "nezha_" + uuid[0:8],
CpuName: cpuName,
Arch: h.Arch,
CpuCores: len(h.Cpu),
OS: osName,
KernelVersion: h.PlatformVersion,
Virtualization: h.Virtualization,
GpuName: gpuName,
MemTotal: clamp(h.MemTotal),
SwapTotal: clamp(h.SwapTotal),
DiskTotal: clamp(h.DiskTotal),
Version: h.Version,
UpdatedAt: now,
}
// Upsert by UUID; don't override existing Token if already set
updates := map[string]interface{}{
"cpu_name": c.CpuName,
"arch": c.Arch,
"cpu_cores": c.CpuCores,
"os": c.OS,
"kernel_version": c.KernelVersion,
"virtualization": c.Virtualization,
"gpu_name": c.GpuName,
"mem_total": c.MemTotal,
"swap_total": c.SwapTotal,
"disk_total": c.DiskTotal,
"version": c.Version,
"updated_at": time.Now(),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(&c).Error
}
// ingestState maps Nezha State into common.Report then saves a Record
func ingestState(uuid string, st *proto.State) error {
// we may need totals from client
db := dbcore.GetDBInstance()
var client models.Client
if err := db.Where("uuid = ?", uuid).First(&client).Error; err != nil {
// If missing, create with minimal defaults to avoid failing ingestion
client = models.Client{UUID: uuid, Token: "", Name: "nezha_" + uuid[0:8]}
auditlog.EventLog("info", "auto created client "+client.Name)
_ = db.Create(&client).Error
}
rep := common.Report{
CPU: common.CPUReport{Usage: st.Cpu},
Ram: common.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
Swap: common.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
Load: common.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
Disk: common.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
Network: common.NetworkReport{
Up: int64(st.NetOutSpeed),
Down: int64(st.NetInSpeed),
TotalUp: int64(st.NetOutTransfer),
TotalDown: int64(st.NetInTransfer),
},
Uptime: int64(st.Uptime),
Process: int(st.ProcessCount),
UpdatedAt: time.Now(),
}
// 更新实时缓存供前端使用
ws.SetLatestReport(uuid, &rep)
// 写入内存缓存,入库交由定时聚合任务处理
return apiClient.SaveClientReport(uuid, rep)
}
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*proto.GeoIP, error) {
uuid, _, err := getAuth(ctx)
if err != nil {
return nil, err
}
updates := map[string]interface{}{
"updated_at": time.Now(),
}
var iso string
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
if 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
}
}
}
}
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
updates["ipv6"] = v6
if iso == "" { // 优先使用 v4 的国家码
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
}
}
}
}
}
}
if iso != "" {
// UI 使用旗帜 emoji
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
}
if len(updates) > 0 {
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
}
// 回写 GeoIP(包含国家码与面板启动时间)
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
return resp, nil
}
+14 -4
View File
@@ -1,11 +1,14 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/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)
}
+56 -6
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"log/slog"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -17,25 +19,73 @@ func GetEnv(key, defaultValue string) string {
return value
}
// 从环境变量获取默认值
var (
configFileEnv = GetEnv("KOMARI_CONFIG_FILE", "./data/komari.json")
)
var RootCmd = &cobra.Command{
Use: "Komari",
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)
}
}
// 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.DatabaseType, "db-type", "t", "sqlite", "Database type (sqlite)")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "SQLite database file path")
// 设置命令行参数,提供环境变量作为默认值
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]")
}
+13 -108
View File
@@ -1,14 +1,13 @@
package cmd
import (
"errors"
"context"
"os"
"time"
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/cmd/flags"
"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"
)
@@ -17,111 +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)
}
// RunServer 按显式的生命周期阶段启动服务端。
//
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
if err := frontendpublic.PrepareDefaultDist(); err != nil {
logger.Fatalf("server", "prepare default frontend failed: %v", err)
}
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
if err := app.Bootstrap(); err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
}
installRequired, err := app.InstallRequired()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", err)
}
if installRequired {
completed, err := app.RunInstallGuide()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
}
if !completed {
return
}
}
for {
requirement, err := app.DatabaseMigrationRequired()
if err != nil {
completed, recoveryErr := app.RunMetricStoreRecovery(err)
if recoveryErr != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "database-migration-detection-recovery", recoveryErr)
}
if !completed {
return
}
continue
}
if !requirement.Required() {
break
}
completed, err := app.RunDatabaseMigration(requirement)
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-database-migration", err)
}
if !completed {
return
}
}
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
if err := app.ConnectMetricStoreWithRetry(); err != nil {
completed, recoveryErr := app.RunMetricStoreRecovery(err)
if recoveryErr != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
}
if !completed {
return
}
}
// 其余初始化阶段:任一步失败都不应继续对外服务。
type stage struct {
name string
fn func() error
}
stages := []stage{
{"init-stores", app.InitStores},
{"init-providers", app.InitProviders},
{"start-background", app.StartBackground},
{"build-router", app.BuildRouter},
}
for _, s := range stages {
if err := s.fn(); err != nil {
// 已登记的资源尽力回收,再退出。
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
}
}
if err := app.Run(); err != nil {
if errors.Is(err, appserver.ErrRestartRequested) {
logger.Infof("server", "Service stopped for restart: %v", err)
os.Exit(1)
}
logger.Fatalf("server", "server exited with error: %v", err)
}
}
-48
View File
@@ -1,48 +0,0 @@
package accounts
import (
"image"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/pquerna/otp/totp"
)
var (
TwoFactorIssuer = "Komari Monitor"
)
func Generate2Fa() (string, image.Image, error) {
otp, err := totp.Generate(totp.GenerateOpts{
Issuer: TwoFactorIssuer,
AccountName: "komari",
})
if err != nil {
return "", nil, err
}
img, err := otp.Image(250, 250)
if err != nil {
return "", nil, err
}
return otp.Secret(), img, nil
}
func Enable2Fa(uuid, secret string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
}
func Verify2Fa(uuid, code string) (bool, error) {
db := dbcore.GetDBInstance()
var user models.User
if err := db.Where("uuid = ?", uuid).First(&user).Error; err != nil {
return false, err
}
// 用户未启用2FA时视为校验失败
return user.TwoFactor != "" && totp.Validate(code, user.TwoFactor), nil
}
func Disable2Fa(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
}
-122
View File
@@ -1,122 +0,0 @@
package accounts
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"github.com/google/uuid"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
)
const constantSalt = "06Wm4Jv1Hkxx"
// CheckPassword 检查密码,正确时返回用户 UUID 和 true
func CheckPassword(username, passwd string) (uuid string, ok bool) {
db := dbcore.GetDBInstance()
var user models.User
result := db.Where("username = ?", username).First(&user)
if result.Error != nil {
return "", false
}
if hashPasswd(passwd) != user.Passwd {
return "", false
}
return user.UUID, true
}
// ForceResetPassword 强制重置用户密码
func ForceResetPassword(username, passwd string) error {
db := dbcore.GetDBInstance()
result := db.Model(&models.User{}).Where("username = ?", username).Update("passwd", hashPasswd(passwd))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("无法找到用户名")
}
return nil
}
// hashPasswd 对密码进行加盐哈希
func hashPasswd(passwd string) string {
h := sha256.Sum256([]byte(passwd + constantSalt))
return base64.StdEncoding.EncodeToString(h[:])
}
func CreateAccount(username, passwd string) (user models.User, err error) {
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
}
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (models.User, error) {
user := models.User{
UUID: uuid.New().String(),
Username: username,
Passwd: hashPasswd(passwd),
}
return user, db.Create(&user).Error
}
func DeleteAccountByUsername(username string) (err error) {
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
}
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) error {
return db.Where("username = ?", username).Delete(&models.User{}).Error
}
func GetUserByUUID(uuid string) (models.User, error) {
var user models.User
return user, dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&user).Error
}
// GetUserBySSO 通过 SSO 信息获取用户
func GetUserBySSO(ssoID string) (models.User, error) {
db := dbcore.GetDBInstance()
var user models.User
if err := db.Where("sso_id = ?", ssoID).First(&user).Error; err != nil {
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
}
return user, nil
}
func BindingExternalAccount(uuid string, sso_id string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
}
func UnbindExternalAccount(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
}
func UpdateUser(uuid string, name, password, sso_type *string) error {
db := dbcore.GetDBInstance()
// Check if user exists
var existingUser models.User
result := db.Where("uuid = ?", uuid).First(&existingUser)
if result.Error != nil {
return fmt.Errorf("user not found: %s", uuid)
}
updates := make(map[string]interface{})
if name != nil {
updates["username"] = *name
}
if password != nil {
updates["passwd"] = hashPasswd(*password)
}
if sso_type != nil {
updates["sso_type"] = *sso_type
}
updates["updated_at"] = time.Now().UTC()
if err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error; err != nil {
return err
}
if password != nil {
DeleteAllSessions()
}
return nil
}
-108
View File
@@ -1,108 +0,0 @@
package accounts
import (
"errors"
"fmt"
"net"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
// GetAllSessions 获取所有会话
func GetAllSessions() ([]models.Session, error) {
db := dbcore.GetDBInstance()
var sessions []models.Session
return sessions, db.Find(&sessions).Error
}
// CreateSession 创建新会话
func CreateSession(uuid string, expires int, userAgent, ip, login_method string) (string, error) {
db := dbcore.GetDBInstance()
session := utils.GenerateRandomString(32)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now().UTC(),
}
go func() {
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
if LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil && ipinfo.Name != "" {
loc = ipinfo.Name
}
_ = messageSender.SendNotification(models.EventMessage{
Event: messageevent.Login,
Time: time.Now().UTC(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
}
}()
return session, db.Create(&sessionRecord).Error
}
// GetSession 根据会话 ID 获取 UUID
func GetSession(session string) (string, error) {
db := dbcore.GetDBInstance()
var sessionRecord models.Session
if err := db.Where("session = ?", session).First(&sessionRecord).Error; err != nil {
return "", err
}
if time.Now().UTC().After(sessionRecord.Expires) {
// 会话已过期,删除它
_ = DeleteSession(session)
return "", errors.New("session expired")
}
return sessionRecord.UUID, nil
}
func GetUserBySession(session string) (models.User, error) {
var sessionRecord models.Session
if err := dbcore.GetDBInstance().Where("session = ?", session).First(&sessionRecord).Error; err != nil {
return models.User{}, err
}
return GetUserByUUID(sessionRecord.UUID)
}
// DeleteSession 删除指定会话
func DeleteSession(session string) error {
db := dbcore.GetDBInstance()
return db.Where("session = ?", session).Delete(&models.Session{}).Error
}
func DeleteAllSessions() error {
db := dbcore.GetDBInstance()
return db.Where("1 = 1").Delete(&models.Session{}).Error
}
func UpdateLatest(session, useragent, ip string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
"latest_online": time.Now().UTC(),
"latest_user_agent": useragent,
"latest_ip": ip,
}).Error
}
func RemoveExpiredSessions() error {
db := dbcore.GetDBInstance()
return db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{}).Error
}
-36
View File
@@ -1,36 +0,0 @@
package auditlog
import (
logger "github.com/komari-monitor/komari/utils/log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func Log(ip, uuid, message, msgType string) {
db := dbcore.GetDBInstance()
logEntry := &models.Log{
IP: ip,
UUID: uuid,
Message: message,
MsgType: msgType,
Time: time.Now().UTC(),
}
if err := db.Create(logEntry).Error; err != nil {
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
}
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
threshold := time.Now().UTC().AddDate(0, 0, -30)
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
}
}
-221
View File
@@ -1,221 +0,0 @@
package clients
import (
"encoding/json"
"fmt"
logger "github.com/komari-monitor/komari/utils/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"
"github.com/google/uuid"
)
func DeleteClient(clientUuid string) error {
return dbcore.GetDBInstance().Delete(&models.Client{}, "uuid = ?", clientUuid).Error
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
update["updated_at"] = time.Now().UTC()
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
}
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
}
// 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: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := db.Create(&client).Error; err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
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: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := db.Create(&client).Error; err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
func GetClientByUUID(uuid string) (models.Client, error) {
var client models.Client
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (string, error) {
var client models.Client
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() ([]models.Client, error) {
var clients []models.Client
if err := dbcore.GetDBInstance().Find(&clients).Error; 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 v, exists := updates["traffic_limit"]; exists {
if val, ok := v.(float64); ok {
if val < 0 || val > math.MaxInt64-1 {
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
}
}
}
if value, exists := updates["expired_at"]; exists {
switch typed := value.(type) {
case nil:
updates["expired_at"] = nil
case time.Time:
updates["expired_at"] = typed.UTC()
case *time.Time:
if typed == nil {
updates["expired_at"] = nil
} else {
updates["expired_at"] = typed.UTC()
}
case string:
stamp, err := time.Parse(time.RFC3339Nano, typed)
if err != nil {
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
}
updates["expired_at"] = stamp.UTC()
default:
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
}
}
updates["updated_at"] = time.Now().UTC()
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
}
-97
View File
@@ -1,97 +0,0 @@
package clients
import (
"fmt"
"math"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
v2 "github.com/komari-monitor/komari/protocol/v2"
)
func GetClientUUIDByToken(token string) (string, error) {
var client models.Client
if err := dbcore.GetDBInstance().Where("token = ?", token).First(&client).Error; err != nil {
return "", err
}
return client.UUID, nil
}
// ReportVerify 检查上报数据,防止异常数据导致数据库损坏
func ReportVerify(report v2.Report) error {
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
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] 防止数据溢出
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
return err
}
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
return err
}
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数 Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
}
if report.Connections.UDP < 0 {
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
}
return nil
}
-487
View File
@@ -1,487 +0,0 @@
package dbcore
import (
"archive/zip"
"fmt"
"io"
"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/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
"github.com/komari-monitor/komari/internal/sqlitetune"
logger "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// 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
initErr error
)
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
// 也避免额外的裸文件依赖。
const SystemVersionKey = "system_version"
const (
mainSQLiteBusyTimeout = 5 * time.Second
mainSQLiteCacheSizeKB = 8 * 1024
mainSQLiteWALAutoCheckpoint = 256
mainSQLiteJournalSizeLimit = 1 << 20
)
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
// nil means a fresh install; an empty value means an unmarked legacy install.
var lastRunVersion *string
func LastRunVersion() *string {
return lastRunVersion
}
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
// 之前采集。
var dbFileExistedAtStartup bool
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
func SetVersionID(id string) {
versionID = id
}
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
func resolveDatabaseFile() string {
dbFile := flags.DatabaseFile
if dbFile == "" {
dbFile = "./data/komari.db"
}
return dbFile
}
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
// ./data/backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
//
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
//
// 触发规则:
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
// - 配置中版本与当前不同:版本升级,备份。
// - 配置中版本与当前一致:无需备份。
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
lastRunVersion = nil
if versionRecorded || dbFileExistedAtStartup {
lastRunVersion = &prevVersion
}
if versionID == "" {
return
}
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
return
}
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
if !versionRecorded && !dbFileExistedAtStartup {
writeVersionMarker()
return
}
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
// 避免备份出的库缺少仍留在 -wal 中的写入。
if instance != nil {
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
backupDir := filepath.Join(".", "data", "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
return
}
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join(backupDir, fmt.Sprintf("upgrade-%s.zip", tsName))
backupZipPath := filepath.Join(".", "data", "backup.zip")
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
return
}
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
writeVersionMarker()
}
// writeVersionMarker 将当前 versionID 写入配置库。
func writeVersionMarker() {
if err := config.Set(SystemVersionKey, versionID); err != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
}
}
func buildSQLiteDSN(databaseFile string) string {
if databaseFile == "" {
databaseFile = "./data/komari.db"
}
params := fmt.Sprintf("_busy_timeout=%d&_txlock=immediate", mainSQLiteBusyTimeout.Milliseconds())
separator := "?"
if strings.Contains(databaseFile, "?") {
separator = "&"
}
if strings.HasPrefix(databaseFile, "file:") {
return databaseFile + separator + params
}
if databaseFile == ":memory:" {
return "file::memory:?cache=shared&" + params
}
return "file:" + filepath.ToSlash(databaseFile) + separator + params
}
func mainSQLiteOptions() sqlitetune.Options {
return sqlitetune.Options{
BusyTimeout: mainSQLiteBusyTimeout,
CacheSizeKB: mainSQLiteCacheSizeKB,
MMapSizeBytes: 0,
TempStoreMemory: false,
CacheSpill: true,
WALAutoCheckpoint: mainSQLiteWALAutoCheckpoint,
JournalSizeLimitBytes: mainSQLiteJournalSizeLimit,
Synchronous: sqlitetune.SynchronousNormal,
}
}
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
func Initialize() error {
once.Do(func() {
initErr = doInitialize()
})
return initErr
}
// GetDBInstance 返回全局数据库实例。
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
// 需要错误处理的启动流程应优先调用 Initialize()。
func GetDBInstance() *gorm.DB {
if err := Initialize(); err != nil {
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
}
return instance
}
// Close 关闭底层数据库连接,供关闭流程调用。
func Close() error {
if instance == nil {
return nil
}
sqlDB, err := instance.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func doInitialize() error {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 将当前数据快照保存到 ./data/backup/,并保留已有归档。
backupDir := filepath.Join(".", "data", "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join(backupDir, fmt.Sprintf("pre-restore-%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
} else {
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除数据文件,但保留归档目录和待恢复的 backup.zip。
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}, backupDir: {}}); delErr != nil {
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
} else {
logger.Infof("dbcore", "[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
} else {
logger.Infof("dbcore", "[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
}
}
}()
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
// gorm.Open 会创建空库。
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
dbFileExistedAtStartup = true
}
logConfig := &gorm.Config{
Logger: logger.NewGormLogger(),
NowFunc: func() time.Time { return time.Now().UTC() },
}
// 根据数据库类型选择不同的连接方式
switch flags.ApplyDatabaseTypeNormalization() {
case flags.DatabaseTypeSQLite:
// _txlock=immediate lets writes acquire their lock before reads can
// turn into a lock-upgrade conflict. sqlitetune applies the remaining
// per-connection PRAGMAs whenever database/sql opens a connection.
dsn := buildSQLiteDSN(flags.DatabaseFile)
sqlDB, dbErr := sqlitetune.Open(dsn, mainSQLiteOptions())
if dbErr != nil {
return fmt.Errorf("open SQLite connection pool: %w", dbErr)
}
// SQLite has one writer. Keeping exactly one durable connection also
// keeps connection-local cache and WAL limits stable for the main DB.
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
sqlDB.SetConnMaxIdleTime(0)
instance, err = gorm.Open(sqlite.New(sqlite.Config{Conn: sqlDB}), logConfig)
if err != nil {
_ = sqlDB.Close()
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
}
if err := instance.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
logger.Errorf("dbcore", "Failed to checkpoint SQLite WAL at startup: %v", err)
}
default:
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
return fmt.Errorf("failed to run startup migrations: %w", err)
}
config.SetDb(instance)
// 配置库就绪后、执行后续 AutoMigrate 之前:
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
backupOnVersionUpgrade()
// 自动迁移模型
//
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
// models.TrafficReportNotification 同样仅为旧数据库兼容保留,不再建表。
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Log{},
&models.OfflineNotification{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
&models.PluginConfiguration{},
)
if err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
if err := instance.AutoMigrate(
&models.Session{},
); err != nil {
logger.Errorf("dbcore", "Failed to create Session table, it may already exist: %v", err)
}
if err := instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
); err != nil {
logger.Errorf("dbcore", "Failed to create Task and TaskResult table, it may already exist: %v", err)
}
return nil
}
-92
View File
@@ -1,92 +0,0 @@
package dbcore
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"sync"
"github.com/komari-monitor/komari/cmd/flags"
)
var maintenanceMu sync.Mutex
// StorageSize returns the bytes occupied by the main SQLite database and its
// WAL/SHM sidecar files.
func StorageSize() (int64, error) {
if !flags.IsSQLite() {
return 0, errors.New("main database size is only available for SQLite")
}
return sqliteFileSetSize(resolveDatabaseFile())
}
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
func ReclaimSpace(ctx context.Context) error {
if !flags.IsSQLite() {
return errors.New("main database maintenance is only supported for SQLite")
}
maintenanceMu.Lock()
defer maintenanceMu.Unlock()
db, err := GetDBInstance().DB()
if err != nil {
return fmt.Errorf("get main database connection: %w", err)
}
if err := checkpointSQLiteWAL(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
return fmt.Errorf("vacuum main database: %w", err)
}
return checkpointSQLiteWAL(ctx, db)
}
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
var busy, logFrames, checkpointedFrames int
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
return fmt.Errorf("checkpoint main database WAL: %w", err)
}
if busy != 0 {
return fmt.Errorf(
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
logFrames,
checkpointedFrames,
)
}
return nil
}
func sqliteFileSetSize(dsn string) (int64, error) {
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
if index := strings.IndexByte(path, '?'); index >= 0 {
path = path[:index]
}
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
return 0, errors.New("database is not backed by a local file")
}
var total int64
foundDatabase := false
for _, suffix := range []string{"", "-wal", "-shm"} {
info, err := os.Stat(path + suffix)
switch {
case err == nil:
total += info.Size()
if suffix == "" {
foundDatabase = true
}
case errors.Is(err, os.ErrNotExist):
continue
default:
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
}
}
if !foundDatabase {
return 0, fmt.Errorf("database file %q does not exist", path)
}
return total, nil
}
-37
View File
@@ -1,37 +0,0 @@
package dbcore
import (
"os"
"path/filepath"
"testing"
)
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
files := map[string]int{
databasePath: 11,
databasePath + "-wal": 7,
databasePath + "-shm": 5,
}
for path, size := range files {
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
if err != nil {
t.Fatalf("sqliteFileSetSize: %v", err)
}
if got != 23 {
t.Fatalf("size = %d, want 23", got)
}
}
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
if _, err := sqliteFileSetSize(dsn); err == nil {
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
}
}
}
-99
View File
@@ -1,99 +0,0 @@
package dbcore
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"github.com/komari-monitor/komari/internal/sqlitetune"
)
func TestMainSQLiteConnectorAppliesBoundedSettings(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
if err != nil {
t.Fatalf("open SQLite connector: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if got := sqlitePragmaInt(t, db, "busy_timeout"); got != mainSQLiteBusyTimeout.Milliseconds() {
t.Fatalf("busy_timeout = %d, want %d", got, mainSQLiteBusyTimeout.Milliseconds())
}
if got := sqlitePragmaInt(t, db, "synchronous"); got != 1 {
t.Fatalf("synchronous = %d, want NORMAL (1)", got)
}
if got := sqlitePragmaInt(t, db, "cache_size"); got != -mainSQLiteCacheSizeKB {
t.Fatalf("cache_size = %d, want %d", got, -mainSQLiteCacheSizeKB)
}
if got := sqlitePragmaInt(t, db, "mmap_size"); got != 0 {
t.Fatalf("mmap_size = %d, want 0", got)
}
if got := sqlitePragmaInt(t, db, "temp_store"); got != 1 {
t.Fatalf("temp_store = %d, want FILE (1)", got)
}
if got := sqlitePragmaInt(t, db, "cache_spill"); got == 0 {
t.Fatal("cache_spill is disabled")
}
if got := sqlitePragmaInt(t, db, "wal_autocheckpoint"); got != mainSQLiteWALAutoCheckpoint {
t.Fatalf("wal_autocheckpoint = %d, want %d", got, mainSQLiteWALAutoCheckpoint)
}
if got := sqlitePragmaInt(t, db, "journal_size_limit"); got != mainSQLiteJournalSizeLimit {
t.Fatalf("journal_size_limit = %d, want %d", got, mainSQLiteJournalSizeLimit)
}
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatalf("read journal_mode: %v", err)
}
if !strings.EqualFold(journalMode, "wal") {
t.Fatalf("journal_mode = %q, want WAL", journalMode)
}
}
func TestMainSQLiteAutoCheckpointKeepsWALBounded(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
if err != nil {
t.Fatalf("open SQLite connector: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err := db.Exec("CREATE TABLE checkpoint_test (payload BLOB NOT NULL)"); err != nil {
t.Fatalf("create checkpoint test table: %v", err)
}
// This produces well beyond one auto-checkpoint interval. The checkpoint
// below reports the WAL frame count observed before its own work begins.
for range 1024 {
if _, err := db.Exec("INSERT INTO checkpoint_test (payload) VALUES (zeroblob(4096))"); err != nil {
t.Fatalf("insert checkpoint test row: %v", err)
}
}
var busy, logFrames, checkpointedFrames int
if err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
t.Fatalf("inspect automatic checkpoint: %v", err)
}
if busy != 0 {
t.Fatalf("automatic checkpoint left a busy WAL: %d", busy)
}
if logFrames >= 2*mainSQLiteWALAutoCheckpoint {
t.Fatalf("WAL retained %d frames after %d-frame auto-checkpoint interval", logFrames, mainSQLiteWALAutoCheckpoint)
}
}
func sqlitePragmaInt(t *testing.T, db *sql.DB, pragma string) int64 {
t.Helper()
var value int64
if err := db.QueryRow("PRAGMA " + pragma).Scan(&value); err != nil {
t.Fatalf("read %s: %v", pragma, err)
}
return value
}
-30
View File
@@ -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)
}
})
}
}
-23
View File
@@ -1,23 +0,0 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
}
// TrafficReportNotification 定义了流量定时报告的数据库模型
type TrafficReportNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
}
-37
View File
@@ -1,37 +0,0 @@
package models
import "time"
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
TaskId uint `json:"task_id" gorm:"not null;index"`
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
Time time.Time `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
// PingTask 表示一次延迟监测任务配置。
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
}
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
func (task PingTask) AppliesToClient(uuid string) bool {
if uuid == "" {
return false
}
for _, client := range task.Clients {
if client == uuid {
return true
}
}
return false
}
-86
View File
@@ -1,86 +0,0 @@
package models
// Plugin is the manifest of an installed plugin (komari-plugin.json).
//
// Name, Description and Author accept either a plain string or an i18n map
// like {"zh_CN": "...", "en": "..."}, mirroring the theme manifest. The
// backend passes the value through and the frontend resolves it against the
// current locale.
type Plugin struct {
Name any `json:"name"`
Short string `json:"short"` // directory name under data/plugin
Description any `json:"description"`
Author any `json:"author"`
Version string `json:"version"`
URL string `json:"url"`
Icon string `json:"icon"`
Komari string `json:"komari"` // supported server version constraint, e.g. ">=0.0.1"
Entry string `json:"entry"` // entry script, defaults to "script.js"
Permissions PluginPermissions `json:"permissions"`
Configuration Configuration `json:"configuration"` // declared config items, same shape as themes
Pages []PluginPage `json:"pages,omitempty"` // injected admin pages
}
// PluginPermissions declares the plugin capabilities that require admin
// approval. Every field defaults to its zero value: nothing is granted
// unless declared.
//
// The following capabilities are always granted and never require approval:
// reading the plugin's own configuration (server.getConfig), registering
// plugin-owned RPC methods (server.registerRPC), and reading/writing files
// inside the plugin directory.
type PluginPermissions struct {
Node bool `json:"node"` // Node.js compatibility modules (runtime setting, not approval-relevant)
AllowSystemRPC bool `json:"allowSystemRPC"` // server.call: call system RPC methods with admin authority
AllowRoutes bool `json:"allowRoutes"` // server.route: register HTTP routes on the host engine
AllowHooks bool `json:"allowHooks"` // server.hook: modify HTTP requests/responses and intercept WebSocket connections/frames
AllowHTMLInject bool `json:"allowHTMLInject"` // server.injectHTML: embed CSS/JS into every HTML response
AllowExec bool `json:"allowExec"` // child_process: execute child processes
AllowListen bool `json:"allowListen"` // net/http servers: listen on local ports
AllowAllFileAccess bool `json:"allowAllFileAccess"` // access files outside the plugin directory
MaxHTTPBodyBytes int64 `json:"maxHTTPBodyBytes"` // runtime limit, not approval-relevant
MaxChildOutputBytes int `json:"maxChildOutputBytes"` // runtime limit, not approval-relevant
TimeoutSeconds int `json:"timeout"` // per-turn execution timeout in seconds, not approval-relevant
}
// PageVisibility controls who can reach a plugin page.
type PageVisibility string
const (
// PageVisibilityAdmin limits the page to the admin sidebar.
PageVisibilityAdmin PageVisibility = "admin"
// PageVisibilityPublic serves the page through the public route without
// navigation, mirroring the theme public behavior.
PageVisibilityPublic PageVisibility = "public"
)
// PageType controls how a plugin page is presented.
type PageType string
const (
// PageTypeIframe embeds a static file from the plugin directory.
PageTypeIframe PageType = "iframe"
// PageTypeRedirect navigates to an internal site path, mirroring the
// theme redirect configuration. The URL must stay on the same origin.
PageTypeRedirect PageType = "redirect"
)
// PluginPage declares a page provided by the plugin. An iframe page is served
// from the plugin directory (File); a redirect page navigates to an internal
// site path (URL). Visibility defaults to admin so existing packages keep
// their current behavior.
type PluginPage struct {
File string `json:"file"`
Title any `json:"title"`
Icon string `json:"icon"`
Type PageType `json:"type,omitempty"`
URL string `json:"url,omitempty"`
Visibility PageVisibility `json:"visibility,omitempty"`
}
// PluginConfiguration stores the saved configuration values of one plugin,
// mirroring ThemeConfiguration.
type PluginConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
-167
View File
@@ -1,167 +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 any `json:"name"` // 主题名称,支持字符串或多语言对象
Short string `json:"short"` // 短名称,用作文件夹名
Description any `json:"description"` // 主题描述,支持字符串或多语言对象
Version string `json:"version"` // 版本号
Author any `json:"author"` // 作者,支持字符串或多语言对象
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Configuration Configuration `json:"configuration"` // 声明配置项
}
// IsLocalizedText reports whether a manifest text field has a usable value.
func IsLocalizedText(value any) bool {
switch text := value.(type) {
case string:
return strings.TrimSpace(text) != ""
case map[string]any:
for _, item := range text {
if itemText, ok := item.(string); ok && strings.TrimSpace(itemText) != "" {
return true
}
}
case map[string]string:
for _, item := range text {
if strings.TrimSpace(item) != "" {
return true
}
}
}
return false
}
type Configuration struct {
Type string `json:"type"` // managed raw redirect
Icon string `json:"icon"` // 图标
Name any `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name any `json:"name"`
Required bool `json:"required"`
Type string `json:"type"` // string number select switch title textbox richtext nodes pingtasks
Options string `json:"options"`
Default any `json:"default"`
Help any `json:"help"`
}
type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
func (t Theme) ConfigurationType() string {
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
if typ == "" {
return ThemeConfigurationManaged
}
return typ
}
func (t Theme) RawHTML() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRaw {
return "", false
}
return configurationDataString(t.Configuration.Data)
}
func (t Theme) RedirectTarget() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRedirect {
return "", false
}
return NormalizeThemeRedirectTarget(t.Configuration.Data)
}
func (t Theme) ValidateConfiguration() error {
switch t.ConfigurationType() {
case ThemeConfigurationManaged:
return nil
case ThemeConfigurationRaw:
html, ok := t.RawHTML()
if !ok || strings.TrimSpace(html) == "" {
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
}
return nil
case ThemeConfigurationRedirect:
if _, ok := t.RedirectTarget(); !ok {
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
}
return nil
default:
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
}
}
func NormalizeThemeRedirectTarget(data any) (string, bool) {
target, ok := configurationDataString(data)
if !ok {
return "", false
}
target = strings.TrimSpace(target)
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
return "", false
}
parsed, err := url.Parse(target)
if err != nil || parsed.IsAbs() || parsed.Host != "" {
return "", false
}
cleanInputPath := parsed.Path
if strings.HasPrefix(cleanInputPath, "/") {
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
} else {
for strings.HasPrefix(cleanInputPath, "../") {
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
}
}
for _, segment := range strings.Split(cleanInputPath, "/") {
if segment == ".." {
return "", false
}
}
cleanPath := cleanInputPath
if cleanPath == "" {
cleanPath = "/"
} else {
cleanPath = path.Clean(cleanPath)
if cleanPath == "." {
cleanPath = "/"
} else {
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
}
}
normalized := url.URL{
Path: cleanPath,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
return normalized.String(), true
}
func configurationDataString(data any) (string, bool) {
value, ok := data.(string)
return value, ok
}
-141
View File
@@ -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)
}
})
}
}
-77
View File
@@ -1,77 +0,0 @@
package models
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time { return time.Now().UTC() },
})
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.AutoMigrate(&Log{}); err != nil {
t.Fatalf("migrate log: %v", err)
}
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
if err := db.Create(&entry).Error; err != nil {
t.Fatalf("create log: %v", err)
}
var loaded Log
if err := db.First(&loaded, entry.ID).Error; err != nil {
t.Fatalf("load log: %v", err)
}
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
}
encoded, err := json.Marshal(loaded)
if err != nil {
t.Fatalf("marshal log: %v", err)
}
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
t.Fatalf("unexpected JSON: %s", encoded)
}
}
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.AutoMigrate(&Client{}); err != nil {
t.Fatalf("migrate client: %v", err)
}
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
t.Fatalf("insert client: %v", err)
}
var client Client
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
t.Fatalf("load client: %v", err)
}
if client.ExpiredAt != nil {
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
}
encoded, err := json.Marshal(client)
if err != nil {
t.Fatalf("marshal client: %v", err)
}
if !strings.Contains(string(encoded), `"expired_at":null`) {
t.Fatalf("unexpected JSON: %s", encoded)
}
}
-24
View File
@@ -1,24 +0,0 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
db := dbcore.GetDBInstance()
var config models.OidcProvider
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func DeleteOidcConfigByName(name string) error {
db := dbcore.GetDBInstance()
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
}
func SaveOidcConfig(config *models.OidcProvider) error {
return dbcore.GetDBInstance().Save(config).Error
}
-63
View File
@@ -1,63 +0,0 @@
package records
import "github.com/komari-monitor/komari/database/models"
// LoadProjection is the field subset selected by a load_type request.
type LoadProjection struct {
Cpu *float32 `json:"cpu,omitempty"`
Gpu *float32 `json:"gpu,omitempty"`
Ram *int64 `json:"ram,omitempty"`
RamTotal *int64 `json:"ram_total,omitempty"`
Swap *int64 `json:"swap,omitempty"`
SwapTotal *int64 `json:"swap_total,omitempty"`
Load *float32 `json:"load,omitempty"`
Temp *float32 `json:"temp,omitempty"`
Disk *int64 `json:"disk,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetIn *int64 `json:"net_in,omitempty"`
NetOut *int64 `json:"net_out,omitempty"`
NetTotalUp *int64 `json:"net_total_up,omitempty"`
NetTotalDown *int64 `json:"net_total_down,omitempty"`
Process *int `json:"process,omitempty"`
Connections *int `json:"connections,omitempty"`
ConnectionsUdp *int `json:"connections_udp,omitempty"`
}
// ProjectLoad returns the load_type projection for one record.
func ProjectLoad(r models.Record, loadType string) LoadProjection {
var p LoadProjection
switch loadType {
case "cpu":
p.Cpu = ptr(r.Cpu)
case "gpu":
p.Gpu = ptr(r.Gpu)
case "ram":
p.Ram = ptr(r.Ram)
p.RamTotal = ptr(r.RamTotal)
case "swap":
p.Swap = ptr(r.Swap)
p.SwapTotal = ptr(r.SwapTotal)
case "load":
p.Load = ptr(r.Load)
case "temp":
p.Temp = ptr(r.Temp)
case "disk":
p.Disk = ptr(r.Disk)
p.DiskTotal = ptr(r.DiskTotal)
case "network":
p.NetIn = ptr(r.NetIn)
p.NetOut = ptr(r.NetOut)
p.NetTotalUp = ptr(r.NetTotalUp)
p.NetTotalDown = ptr(r.NetTotalDown)
case "process":
p.Process = ptr(r.Process)
case "connections":
p.Connections = ptr(r.Connections)
p.ConnectionsUdp = ptr(r.ConnectionsUdp)
default:
p.Cpu = ptr(r.Cpu)
}
return p
}
func ptr[T any](v T) *T { return &v }
-95
View File
@@ -1,95 +0,0 @@
package records
import (
"context"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/metricstore"
)
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
//
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
func DeleteAll() error {
return metricstore.DeleteAllRecords(context.Background())
}
// QueryGPURecords returns GPU records using the caller's cancellation context.
func QueryGPURecords(ctx context.Context, uuid string, start, end time.Time) ([]models.GPURecord, error) {
return metricstore.GetGPURecordsByClientAndTime(ctx, uuid, start, end)
}
// GetGPURecordsByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryGPURecords.
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
return QueryGPURecords(context.Background(), uuid, start, end)
}
// RecordQueryOptions narrows metric selection and the response point budget.
type RecordQueryOptions = metricstore.RecordQueryOptions
// LoadTypeMetricNames maps the RPC load_type to the stored metrics that back
// that field. An empty result means "all load metrics" (including temp and
// unknown values, which stay on the compatible full-query path).
func LoadTypeMetricNames(loadType string) []string {
var names []string
switch loadType {
case "cpu":
return []string{metricstore.MetricCPU}
case "gpu":
names = []string{metricstore.MetricGPU}
case "ram":
names = []string{metricstore.MetricRAM}
case "swap":
names = []string{metricstore.MetricSwap}
case "load":
names = []string{metricstore.MetricLoad}
case "disk":
names = []string{metricstore.MetricDisk}
case "network":
names = []string{metricstore.MetricNetIn, metricstore.MetricNetOut, metricstore.MetricNetTotalUp, metricstore.MetricNetTotalDown}
case "process":
names = []string{metricstore.MetricProcess}
case "connections":
names = []string{metricstore.MetricConnections, metricstore.MetricConnectionsUDP}
default:
return nil
}
// CPU is the common presence anchor: the previous implementation built one
// record per timestamp where any load metric existed. Including CPU keeps
// those timestamps (and zero values for a missing selected metric) without
// going back to all fifteen metrics.
return append(names, metricstore.MetricCPU)
}
// QueryRecords returns one client's records, or all clients when uuid is empty.
// Request handlers should use this context-aware entry point.
func QueryRecords(ctx context.Context, uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
if uuid == "" {
return metricstore.GetRecordsByTime(ctx, start, end, opts...)
}
return metricstore.GetRecordsByClientAndTime(ctx, uuid, start, end, opts...)
}
// GetRecordsByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryRecords.
func GetRecordsByClientAndTime(uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end, opts...)
}
// GetRecordMetricMaxByClientAndTime is a compatibility-only wrapper without cancellation.
// New request handlers should use metricstore.GetRecordMetricMaxByClientAndTime.
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
}
// GetRecordsByTime is a compatibility-only wrapper without cancellation.
// New request handlers should use QueryRecords with an empty uuid.
func GetRecordsByTime(start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end, opts...)
}
-191
View File
@@ -1,191 +0,0 @@
package tasks
import (
"context"
"slices"
"sort"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/metricstore"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
)
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
db := dbcore.GetDBInstance()
normalizedClients := normalizePingClients(models.StringArray(clients))
task := models.PingTask{
Clients: normalizedClients,
DefaultOn: defaultOn,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&task).Error; err != nil {
return err
}
// Append by id to avoid races between concurrent create requests.
return tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id)).Error
})
if err != nil {
return 0, err
}
ReloadPingSchedule()
return task.Id, nil
}
func DeletePingTask(id []uint) error {
// The metric store is independent from the main database, so clean it first
// to avoid leaving history that can no longer be addressed through the task.
if err := DeletePingRecords(id); err != nil {
return err
}
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
ReloadPingSchedule()
return result.Error
}
// EditPingTask 批量更新延迟监测任务配置。
func EditPingTask(tasks []*models.PingTask) error {
db := dbcore.GetDBInstance()
for _, task := range tasks {
task.Clients = normalizePingClients(task.Clients)
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
updates := map[string]interface{}{
"name": task.Name,
"clients": task.Clients,
"all_clients": task.DefaultOn,
"type": task.Type,
"target": task.Target,
"interval": task.Interval,
}
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
ReloadPingSchedule()
return nil
}
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
func normalizePingClients(clients models.StringArray) models.StringArray {
if clients == nil {
return models.StringArray{}
}
return clients
}
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
func GetPingTasksByClient(uuid string) []models.PingTask {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
return nil
}
return tasks
}
func UpdatePingTaskOrder(order map[uint]int) error {
if len(order) == 0 {
return nil
}
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
// Validate all ids before changing any weights. The update response is
// not a reliable existence check because some drivers report zero rows
// when the new value equals the current value.
ids := make([]uint, 0, len(order))
for id := range order {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var existing int64
if err := tx.Model(&models.PingTask{}).Where("id IN ?", ids).Count(&existing).Error; err != nil {
return err
}
if existing != int64(len(ids)) {
return gorm.ErrRecordNotFound
}
for _, id := range ids {
if err := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", order[id]).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
ReloadPingSchedule()
return nil
}
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
// metric store,旧 ping_records 表不再参与。
func SavePingRecord(record models.PingRecord) error {
return metricstore.WritePingRecord(context.Background(), record)
}
func DeletePingRecords(id []uint) error {
return metricstore.DeletePingRecordsByTask(context.Background(), id)
}
func DeleteAllPingRecords() error {
return metricstore.DeleteAllPingRecords(context.Background())
}
func ReloadPingSchedule() error {
pingTasks, err := GetAllPingTasks()
if err != nil {
return err
}
return utils.ReloadPingSchedule(pingTasks)
}
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
func AddDefaultOnClientUUID(uuid string) error {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
return err
}
changed := false
for _, task := range tasks {
if slices.Contains(task.Clients, uuid) {
continue
}
next := append(models.StringArray{}, task.Clients...)
next = append(next, uuid)
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
return err
}
changed = true
}
if changed {
return ReloadPingSchedule()
}
return nil
}
-180
View File
@@ -1,180 +0,0 @@
package tasks
import (
"math"
"sort"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/sampling"
)
// PingClientSummary is the per-client aggregate shared by ping RPC adapters.
type PingClientSummary struct {
Total int
Loss int
Min int
Max int
LossPercent float64
}
// PingTaskSummary is the per-task aggregate shared by ping RPC adapters.
type PingTaskSummary struct {
Total int
Loss int
Valid int
Min int
Max int
Avg int
Latest int
P50 int
P99 int
LossPercent float64
P99P50Ratio float64
}
// PingSummary aggregates records once so legacy and public ping handlers only
// differ in response shape.
type PingSummary struct {
Clients map[string]PingClientSummary
Tasks map[uint]PingTaskSummary
}
type pingClientAccumulator struct {
total, loss, min, max int
}
type pingTaskAccumulator struct {
total, loss, min, max, sum, valid int
latest int
latestTime time.Time
latencies []int
}
// SummarizePingRecords computes client and task aggregates in one pass.
func SummarizePingRecords(recs []models.PingRecord) PingSummary {
clients := make(map[string]*pingClientAccumulator)
taskMap := make(map[uint]*pingTaskAccumulator)
for _, r := range recs {
cs := clients[r.Client]
if cs == nil {
cs = &pingClientAccumulator{}
clients[r.Client] = cs
}
cs.total++
ts := taskMap[r.TaskId]
if ts == nil {
ts = &pingTaskAccumulator{latest: -1}
taskMap[r.TaskId] = ts
}
ts.total++
if r.Value < 0 {
cs.loss++
ts.loss++
continue
}
if cs.min == 0 || r.Value < cs.min {
cs.min = r.Value
}
if r.Value > cs.max {
cs.max = r.Value
}
ts.valid++
ts.sum += r.Value
if ts.min == 0 || r.Value < ts.min {
ts.min = r.Value
}
if r.Value > ts.max {
ts.max = r.Value
}
if ts.latestTime.IsZero() || r.Time.After(ts.latestTime) {
ts.latestTime = r.Time
ts.latest = r.Value
}
ts.latencies = append(ts.latencies, r.Value)
}
out := PingSummary{Clients: make(map[string]PingClientSummary, len(clients)), Tasks: make(map[uint]PingTaskSummary, len(taskMap))}
for client, v := range clients {
loss := 0.0
if v.total > 0 {
loss = float64(v.loss) / float64(v.total) * 100
}
out.Clients[client] = PingClientSummary{Total: v.total, Loss: v.loss, Min: v.min, Max: v.max, LossPercent: loss}
}
for id, v := range taskMap {
loss := 0.0
if v.total > 0 {
loss = float64(v.loss) / float64(v.total) * 100
}
avg := 0
if v.valid > 0 {
avg = v.sum / v.valid
}
p50, p99 := pingPercentiles(v.latencies)
ratio := 0.0
if p50 > 0 && p99 >= p50 {
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
ratio = float64(p99-p50) / adjustedBase
}
out.Tasks[id] = PingTaskSummary{
Total: v.total, Loss: v.loss, Valid: v.valid, Min: v.min, Max: v.max,
Avg: avg, Latest: v.latest, P50: p50, P99: p99, LossPercent: loss, P99P50Ratio: ratio,
}
}
return out
}
// SamplePingRecordsByTask applies the legacy ping maxCount policy: allocate
// points proportionally by task, sample each task evenly and return ascending
// by time. Statistics must be computed from the unsampled records.
func SamplePingRecordsByTask(recs []models.PingRecord, maxCount int) []models.PingRecord {
if maxCount <= 0 || len(recs) <= maxCount {
return recs
}
groups := make(map[uint][]models.PingRecord)
for _, r := range recs {
groups[r.TaskId] = append(groups[r.TaskId], r)
}
meta := make([]sampling.Group[uint], 0, len(groups))
for id, points := range groups {
sort.Slice(points, func(i, j int) bool { return points[i].Time.Before(points[j].Time) })
groups[id] = points
meta = append(meta, sampling.Group[uint]{Key: id, Length: len(points)})
}
targets := sampling.AllocateTargets(meta, maxCount)
out := make([]models.PingRecord, 0, maxCount)
for id, points := range groups {
out = append(out, sampling.SampleEvenly(points, targets[id])...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
func pingPercentiles(values []int) (int, int) {
if len(values) == 0 {
return 0, 0
}
sort.Ints(values)
return pingPercentile(values, 0.50), pingPercentile(values, 0.99)
}
func pingPercentile(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
if percentile <= 0 {
return values[0]
}
if percentile >= 1 {
return values[len(values)-1]
}
pos := float64(len(values)-1) * percentile
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return values[lo]
}
frac := pos - float64(lo)
return int(math.Round(float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac))
}
-120
View File
@@ -1,120 +0,0 @@
package tasks
import (
"testing"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
taskID := "task-cleanup-time"
if err := db.Create(&models.Task{TaskId: taskID, Command: "true"}).Error; err != nil {
t.Fatalf("create task: %v", err)
}
cutoff := time.Date(2026, 7, 17, 12, 0, 0, 123456789, time.UTC)
results := []models.TaskResult{
{TaskId: taskID, Client: "old", CreatedAt: cutoff.Add(-time.Nanosecond)},
{TaskId: taskID, Client: "boundary", CreatedAt: cutoff},
{TaskId: taskID, Client: "new", CreatedAt: cutoff.Add(time.Nanosecond)},
}
if err := db.Create(&results).Error; err != nil {
t.Fatalf("create task results: %v", err)
}
localCutoff := cutoff.In(time.FixedZone("UTC+8", 8*60*60))
if err := ClearTaskResultsByTimeBefore(localCutoff); err != nil {
t.Fatalf("clear task results: %v", err)
}
var remaining []models.TaskResult
if err := db.Where("task_id = ?", taskID).Order("created_at").Find(&remaining).Error; err != nil {
t.Fatalf("load remaining results: %v", err)
}
if len(remaining) != 2 || remaining[0].Client != "boundary" || remaining[1].Client != "new" {
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
}
}
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
tasks := []models.PingTask{
{Name: "third", Weight: 2, Type: "icmp", Target: "third.example", Interval: 60},
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
{Name: "second", Weight: 0, Type: "icmp", Target: "second.example", Interval: 60},
}
if err := db.Create(&tasks).Error; err != nil {
t.Fatalf("create ping tasks: %v", err)
}
ordered, err := GetAllPingTasks()
if err != nil {
t.Fatalf("get ordered ping tasks: %v", err)
}
if len(ordered) != len(tasks) {
t.Fatalf("ordered ping task count = %d, want %d", len(ordered), len(tasks))
}
if ordered[0].Id != tasks[1].Id || ordered[1].Id != tasks[2].Id || ordered[2].Id != tasks[0].Id {
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
}
}
func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_update_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
items := []models.PingTask{
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
{Name: "second", Weight: 1, Type: "icmp", Target: "second.example", Interval: 60},
}
if err := db.Create(&items).Error; err != nil {
t.Fatalf("create ping tasks: %v", err)
}
if err := UpdatePingTaskOrder(map[uint]int{
items[0].Id: 0,
items[1].Id: 2,
}); err != nil {
t.Fatalf("update ping task order: %v", err)
}
var first, second models.PingTask
if err := db.First(&first, items[0].Id).Error; err != nil {
t.Fatalf("load first ping task: %v", err)
}
if err := db.First(&second, items[1].Id).Error; err != nil {
t.Fatalf("load second ping task: %v", err)
}
if first.Weight != 0 || second.Weight != 2 {
t.Fatalf("weights = [%d, %d], want [0, 2]", first.Weight, second.Weight)
}
}
func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_client_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
items := []models.PingTask{
{Name: "third", Weight: 2, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "third.example", Interval: 60},
{Name: "first", Weight: 0, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "first.example", Interval: 60},
{Name: "other", Weight: 1, Clients: models.StringArray{"node-b"}, Type: "icmp", Target: "other.example", Interval: 60},
}
if err := db.Create(&items).Error; err != nil {
t.Fatalf("create ping tasks: %v", err)
}
ordered := GetPingTasksByClient("node-a")
if len(ordered) != 2 || ordered[0].Id != items[1].Id || ordered[1].Id != items[0].Id {
t.Fatalf("ping task order = %#v, want ids [%d %d]", ordered, items[1].Id, items[0].Id)
}
}
-120
View File
@@ -1,120 +0,0 @@
package database
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/managedconfig"
"github.com/komari-monitor/komari/internal/metricstore"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/web/public"
)
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Settings]()
if err != nil {
return nil, err
}
cst := *cstPtr
all, allErr := config.GetAll()
hasKey := func(k string) bool {
if allErr != nil {
return false
}
_, ok := all[k]
return ok
}
// Apply defaults only when a key is missing.
if !hasKey("sitename") {
cst.Sitename = "Komari"
}
if !hasKey("description") {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
if !hasKey("theme") {
cst.Theme = "default"
}
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
// Fallback defaults if we couldn't enumerate keys.
if allErr != nil {
if cst.Sitename == "" {
cst.Sitename = "Komari"
}
if cst.Description == "" {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
}
retention, err := metricstore.GetRetentionSummary(context.Background())
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
if err != nil {
tc.Data = "{}"
}
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
logger.Infof("database", "%v", err)
}
items := themeConfigurationItems(cst.Theme)
if cst.Theme != "default" {
for _, item := range items {
if item.Key == "" {
continue
}
if _, exists := tc_data[item.Key]; !exists {
tc_data[item.Key] = managedconfig.DefaultValue(item)
}
}
}
if err := managedconfig.ResolveForOutput(tc_data, items); err != nil {
return nil, err
}
return gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": retention.AllPositive, // 兼容旧版本主题
"record_preserve_time": retention.MaxDays * 24,
"ping_record_preserve_time": retention.MaxDays * 24,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
}
func themeConfigurationItems(short string) []models.ManagedThemeConfigurationItem {
var manifest models.Theme
if short == "default" {
data, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
if err != nil || json.Unmarshal(data, &manifest) != nil {
return nil
}
} else {
data, err := os.ReadFile(filepath.Join("./data/theme", short, "komari-theme.json"))
if err != nil || json.Unmarshal(data, &manifest) != nil {
return nil
}
}
return managedconfig.Items(manifest.Configuration)
}
+135
View File
@@ -0,0 +1,135 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari は、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Web インターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
[ドキュメント](https://komari-document.pages.dev/) | [Telegram グループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Web インターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. ワンクリックインストールスクリプトを使用する
systemd を使用するディストリビューション(Ubuntu、Debian など)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Docker コンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komari の[GitHub リリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komari を実行します:
```bash
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.24+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. バックエンドをビルドします:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ 1 で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
go build -o komari
```
3. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komari テーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[Crowdin で Komari を翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komari エージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
Issue や Pull Request を歓迎します!
## 謝辞
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star 履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+135
View File
@@ -0,0 +1,135 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
## 快速開始
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
```
2. 執行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.24+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 建置後端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟 1 中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
go build -o komari
```
3. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+135
View File
@@ -0,0 +1,135 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.24+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤 1 中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
3. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+29 -20
View File
@@ -1,53 +1,58 @@
module github.com/komari-monitor/komari
go 1.25.0
go 1.24.0
toolchain go1.24.11
require (
github.com/Calcium-Ion/moejs v0.1.0-alpha.9
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/klauspost/compress v1.17.11
github.com/mattn/go-sqlite3 v1.14.27
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.12.1
golang.org/x/sys v0.33.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
)
require go.yaml.in/yaml/v3 v3.0.5 // indirect
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
require golang.org/x/crypto v0.39.0
require golang.org/x/crypto v0.39.0 // indirect
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
// golang.org/x/net vulnerable to Cross-site Scripting #4
require golang.org/x/net v0.41.0
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
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-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/go-cmp v0.7.0 // 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
@@ -55,16 +60,20 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/text v0.33.0
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys 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
)
+65 -24
View File
@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Calcium-Ion/moejs v0.1.0-alpha.9 h1:WY1ebChegtafhPjHSQx3Q+gfnrjqYwHewHuO9k+v7e0=
github.com/Calcium-Ion/moejs v0.1.0-alpha.9/go.mod h1:9YqHj36EV7pzV/vlEYSw49f3oggeJFdmqcjM9TmDpHs=
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=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -12,17 +12,31 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -31,40 +45,42 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.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-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.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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
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=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.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=
@@ -89,6 +105,7 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
@@ -109,28 +126,47 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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.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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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/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=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -138,11 +174,16 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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=
+133 -1183
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -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() {}
+272
View File
@@ -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
}
+31
View File
@@ -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(&params)
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/internal/config"
"github.com/komari-monitor/komari/internal/metricstore"
"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"
v2 "github.com/komari-monitor/komari/protocol/v2"
"github.com/komari-monitor/komari/utils"
agent_runtime "github.com/komari-monitor/komari/web/agent"
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 {
@@ -59,9 +61,9 @@ func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pi
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
return empty
}
end := time.Now().UTC()
end := time.Now()
start := end.Add(-1 * time.Hour)
recs, err := metricstore.GetPingRecords(context.Background(), uuid, -1, start, end)
recs, err := tasks.GetPingRecords(uuid, -1, start, end)
if err != nil || len(recs) == 0 {
empty := map[string]pingStat{}
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
@@ -106,7 +108,7 @@ func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pi
if r.Value > maxLat {
maxLat = r.Value
}
ts := r.Time
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latest = r.Value
@@ -220,21 +222,21 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
UUID string `json:"uuid"`
}
req.BindParams(&params)
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,19 +264,11 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
// 返回以 uuid 为键的字典(每个 value 自身也包含 uuid 字段)
nodeMap := make(map[string]models.Client, len(cinfo))
nodesMap := make(map[string]models.Client, len(cinfo))
for _, node := range cinfo {
nodeMap[node.UUID] = node
nodesMap[node.UUID] = node
}
return nodeMap, nil
}
func gpuUsageFromReport(rep *v2.Report) float32 {
if rep == nil || rep.GPU == nil {
return 0
}
return float32(rep.GPU.AverageUsage)
return nodesMap, nil
}
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
@@ -293,16 +287,16 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
req.BindParams(&params)
meta := rpc.MetaFromContext(ctx)
latest := agent_runtime.GetLatestReport()
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())
}
@@ -327,33 +321,30 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
}
type recordLike struct {
Client string `json:"client"`
Time time.Time `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
GpuCount int `json:"gpu_count,omitempty"`
GpuAverageUsage float64 `json:"gpu_average_usage,omitempty"`
GpuDetailedInfo []v2.GPUDeviceInfo `json:"gpu_detailed_info,omitempty"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Load5 float32 `json:"load5"`
Load15 float32 `json:"load15"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Online bool `json:"online"`
Uptime int64 `json:"uptime"`
Ping map[string]pingStat `json:"ping"`
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Load5 float32 `json:"load5"`
Load15 float32 `json:"load15"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Online bool `json:"online"`
Uptime int64 `json:"uptime"`
Ping map[string]pingStat `json:"ping"`
}
respMap := make(map[string]recordLike, len(latest))
@@ -361,16 +352,16 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
// 预取所有 ping 任务
pingTasks, _ := tasks.GetAllPingTasks()
appendOne := func(uuid string, rep *v2.Report) {
appendOne := func(uuid string, rep *common.Report) {
if rep == nil {
return
}
stats := getPingStatsForNode(uuid, pingTasks)
rl := recordLike{
Client: uuid,
Time: rep.UpdatedAt,
Time: models.FromTime(rep.UpdatedAt),
Cpu: float32(rep.CPU.Usage),
Gpu: gpuUsageFromReport(rep),
Gpu: 0,
Ram: rep.Ram.Used,
RamTotal: rep.Ram.Total,
Swap: rep.Swap.Used,
@@ -392,11 +383,6 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
Uptime: rep.Uptime,
Ping: stats,
}
if rep.GPU != nil {
rl.GpuCount = rep.GPU.Count
rl.GpuAverageUsage = rep.GPU.AverageUsage
rl.GpuDetailedInfo = rep.GPU.DetailedInfo
}
respMap[uuid] = rl
}
@@ -435,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
@@ -449,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)
}
}
@@ -474,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
}
@@ -490,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
}
@@ -509,29 +489,30 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
}
}
reports := agent_runtime.GetRecentReports(params.UUID)
raw, _ := vars.Records.Get(params.UUID)
reports, _ := raw.([]common.Report)
// 扁平化为 { count, records: [] }
type flatRecord struct {
Client string `json:"client"`
Time time.Time `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
resp := struct {
@@ -550,9 +531,9 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
for _, r := range reports {
fr := flatRecord{
Client: params.UUID,
Time: r.UpdatedAt,
Time: models.FromTime(r.UpdatedAt),
Cpu: float32(r.CPU.Usage),
Gpu: gpuUsageFromReport(&r),
Gpu: 0,
Ram: r.Ram.Used,
RamTotal: r.Ram.Total,
Swap: r.Swap.Used,
+806
View File
@@ -0,0 +1,806 @@
package api_rpc
import (
"context"
"fmt"
"math"
"sort"
"time"
"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"
)
func init() {
Register("getRecords", getRecords)
}
func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
meta := rpc.MetaFromContext(ctx)
var params struct {
Type string `json:"type"` // "load" | "ping"; default "load"
UUID string `json:"uuid"` // client uuid; empty = all clients
Hours int `json:"hours"` // time window in hours; default 1 if start/end not provided
Start string `json:"start"` // RFC3339 start time (optional)
End string `json:"end"` // RFC3339 end time (optional)
LoadType string `json:"load_type"` // for type=load: cpu|gpu|ram|swap|load|temp|disk|network|process|connections|all
TaskID int `json:"task_id"` // for type=ping: optional task id; -1 or omitted means all
MaxCount int `json:"maxCount"` // max number of points; -1 unlimited; default 4000
}
req.BindParams(&params)
// defaults
if params.Type == "" {
params.Type = "load"
}
// parse time window
var startTime, endTime time.Time
if params.Start != "" || params.End != "" {
// allow partial: missing end means now
var err error
if params.End == "" {
endTime = time.Now()
} else {
endTime, err = time.Parse(time.RFC3339, params.End)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid end time", params.End)
}
}
if params.Start == "" {
// default to 1 hour before end
startTime = endTime.Add(-1 * time.Hour)
} else {
start, err := time.Parse(time.RFC3339, params.Start)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid start time", params.Start)
}
startTime = start
}
} else {
hours := params.Hours
if hours <= 0 {
hours = 1 // default 1 hour
}
endTime = time.Now()
startTime = endTime.Add(-time.Duration(hours) * time.Hour)
}
// Hidden filtering for non-admin
isAdmin := meta.Permission == "admin"
hidden := map[string]bool{}
if !isAdmin {
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
for _, c := range cinfo {
if c.Hidden {
hidden[c.UUID] = true
}
}
if params.UUID != "" && hidden[params.UUID] {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID not found", params.UUID)
}
}
switch params.Type {
case "load":
// fetch load records
recs, err := getLoadRecordsCombined(params.UUID, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch records", err.Error())
}
// hidden filter on non-admin
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
// resolve maxCount default for load
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
// optional load_type filtering -> group by client
if params.LoadType != "" && params.LoadType != "all" {
items := filterRecordsByLoadType(recs, params.LoadType)
grouped := make(map[string][]flatRecord)
for _, it := range items {
grouped[it.Client] = append(grouped[it.Client], it)
}
// sort and count
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
// downsample across all clients proportionally
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleFlatRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]flatRecord `json:"records"`
LoadType string `json:"load_type"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, LoadType: params.LoadType, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
}
// default: return full records, grouped by client
grouped := make(map[string][]models.Record)
for _, r := range recs {
grouped[r.Client] = append(grouped[r.Client], r)
}
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleModelRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]models.Record `json:"records"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
case "ping":
taskId := params.TaskID
if taskId == 0 {
taskId = -1
}
recs, err := tasks.GetPingRecords(params.UUID, taskId, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping records", err.Error())
}
// hidden filter
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if r.Client != "" && hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time models.LocalTime `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []map[string]any `json:"tasks"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}
response := &Resp{Count: 0, Records: []RecordsResp{}, From: models.FromTime(startTime), To: models.FromTime(endTime)}
// stats per client
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range recs {
rr := RecordsResp{
TaskId: r.TaskId,
Time: r.Time,
Value: r.Value,
Client: r.Client,
}
st := clientStats[r.Client]
st.total++
if r.Value < 0 {
st.loss++
} else {
if st.min == 0 || r.Value < st.min {
st.min = r.Value
}
if r.Value > st.max {
st.max = r.Value
}
}
clientStats[r.Client] = st
response.Records = append(response.Records, rr)
}
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, st := range clientStats {
if client != "" && !isAdmin && hidden[client] {
continue
}
loss := float64(0)
if st.total > 0 {
loss = float64(st.loss) / float64(st.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: st.min,
Max: st.max,
})
}
}
// tasks summary (always included for ping type; do not expose target field)
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
}
toList := make([]map[string]any, 0, len(pingTasks))
for _, t := range pingTasks {
if taskId != -1 && t.Id != uint(taskId) {
continue
}
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
assigned := false
for _, c := range t.Clients {
if c == params.UUID {
assigned = true
break
}
}
if !assigned {
continue
}
}
total := 0
lossCount := 0
minLat := 0
maxLat := 0
sum := 0
valid := 0
latestVal := -1
var latestTs time.Time
// 收集该任务的所有有效(非丢包)延迟值以计算百分位
latencies := make([]int, 0, 64)
for _, r := range recs {
if r.TaskId != t.Id {
continue
}
if params.UUID != "" && r.Client != params.UUID {
continue
}
total++
if r.Value < 0 { // 丢包
lossCount++
continue
}
valid++
sum += r.Value
latencies = append(latencies, r.Value)
if minLat == 0 || r.Value < minLat {
minLat = r.Value
}
if r.Value > maxLat {
maxLat = r.Value
}
// track latest non-negative value
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latestVal = r.Value
}
}
// 计算 P50 / P99
p50 := 0
p99 := 0
if len(latencies) > 0 {
sort.Ints(latencies)
getPercentileInt := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
if percentile <= 0 {
return values[0]
}
if percentile >= 1 {
return values[len(values)-1]
}
pos := (float64(len(values) - 1)) * percentile
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return values[lo]
}
frac := pos - float64(lo)
v := float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac
return int(math.Round(v))
}
p50 = getPercentileInt(latencies, 0.50)
p99 = getPercentileInt(latencies, 0.99)
}
ratio := 0.0
if p50 > 0 && p99 >= p50 {
jitterMs := float64(p99 - p50)
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
ratio = jitterMs / adjustedBase
}
lossRate := 0.0
if total > 0 {
lossRate = float64(lossCount) / float64(total) * 100
}
avg := 0
if valid > 0 {
avg = sum / valid
}
info := map[string]any{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"loss": lossRate,
"min": minLat,
"max": maxLat,
"avg": avg,
"latest": latestVal,
"total": total,
"p50": p50,
"p99": p99,
"p99_p50_ratio": ratio,
}
if params.UUID == "" && taskId != -1 { // retain existing behavior of exposing clients only when filtering by task
info["clients"] = t.Clients
}
toList = append(toList, info)
}
response.Tasks = toList
// apply maxCount for ping
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
if maxCount != -1 && len(response.Records) > maxCount {
// group records by TaskId for proportional downsampling
taskGroups := make(map[uint][]RecordsResp)
for _, r := range response.Records {
taskGroups[r.TaskId] = append(taskGroups[r.TaskId], r)
}
// sort each group by time
for taskId := range taskGroups {
sort.Slice(taskGroups[taskId], func(i, j int) bool {
return taskGroups[taskId][i].Time.ToTime().Before(taskGroups[taskId][j].Time.ToTime())
})
}
// calculate proportional allocation for each task
type taskMeta struct {
taskId uint
length int
}
groupsMeta := make([]taskMeta, 0, len(taskGroups))
for taskId, records := range taskGroups {
groupsMeta = append(groupsMeta, taskMeta{
taskId: taskId,
length: len(records),
})
}
// use existing allocateTargets function (create compatible struct)
strTargets := allocateTargets(
func() []struct {
name string
length int
} {
result := make([]struct {
name string
length int
}, len(groupsMeta))
for i, meta := range groupsMeta {
result[i] = struct {
name string
length int
}{name: fmt.Sprintf("%d", meta.taskId), length: meta.length}
}
return result
}(),
maxCount,
)
// downsample each task group
downsampledRecords := make([]RecordsResp, 0, maxCount)
samplePingRecords := func(in []RecordsResp, k int) []RecordsResp {
n := len(in)
if k <= 0 || n == 0 {
return []RecordsResp{}
}
if k >= n {
return in
}
out := make([]RecordsResp, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
for taskId, records := range taskGroups {
targetKey := fmt.Sprintf("%d", taskId)
targetCount := strTargets[targetKey]
sampled := samplePingRecords(records, targetCount)
downsampledRecords = append(downsampledRecords, sampled...)
}
response.Records = downsampledRecords
}
response.Count = len(response.Records)
// sort by time asc
sort.Slice(response.Records, func(i, j int) bool {
return response.Records[i].Time.ToTime().Before(response.Records[j].Time.ToTime())
})
return response, nil
default:
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid type, expected 'load' or 'ping'", params.Type)
}
}
// ---------- helpers for load records ----------
// getLoadRecordsCombined fetches records for a client or all clients within a time range,
// combining recent short-term table and long-term table with 15-min grouping for recent part.
func getLoadRecordsCombined(uuid string, start, end time.Time) ([]models.Record, error) {
// prefer the existing function when uuid provided
if uuid != "" {
return recordsdb.GetRecordsByClientAndTime(uuid, start, end)
}
db := dbcore.GetDBInstance()
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recent []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
_ = db.Table("records").Where("time >= ? AND time <= ?", recentStart, end).Order("time ASC").Find(&recent).Error
}
var longTerm []models.Record
_ = db.Table("records_long_term").Where("time >= ? AND time <= ?", start, end).Order("time ASC").Find(&longTerm).Error
// if no long term, return all recent
if len(longTerm) == 0 {
return recent, nil
}
// group recent by client+15min, keep latest in bucket
type key struct {
c string
slot string
}
grouped := make(map[key]models.Record)
for _, rec := range recent {
k := key{c: rec.Client, slot: rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)}
if old, ok := grouped[k]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[k] = rec
}
}
flat := make([]models.Record, 0, len(grouped))
for _, rec := range grouped {
flat = append(flat, rec)
}
sort.Slice(flat, func(i, j int) bool { return flat[i].Time.ToTime().Before(flat[j].Time.ToTime()) })
flat = append(flat, longTerm...)
return flat, nil
}
// ---------- downsampling helpers ----------
// allocateTargets splits maxTotal across groups proportionally to their lengths.
func allocateTargets(groups []struct {
name string
length int
}, maxTotal int) map[string]int {
result := make(map[string]int, len(groups))
if maxTotal < 0 {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
total := 0
for _, g := range groups {
total += g.length
}
if total <= maxTotal {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
// initial allocation based on proportion
type rem struct {
idx int
frac float64
}
remainders := make([]rem, 0, len(groups))
sumTargets := 0
for i, g := range groups {
if g.length <= 0 {
result[g.name] = 0
continue
}
raw := float64(g.length) * float64(maxTotal) / float64(total)
t := int(math.Floor(raw))
if t > g.length {
t = g.length
}
result[groups[i].name] = t
sumTargets += t
remainders = append(remainders, rem{i, raw - float64(t)})
}
// distribute remaining by largest fractional parts
if sumTargets < maxTotal {
need := maxTotal - sumTargets
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac > remainders[j].frac })
for _, r := range remainders {
if need == 0 {
break
}
g := groups[r.idx]
cur := result[g.name]
if cur < g.length {
result[g.name] = cur + 1
need--
}
}
// if still left, second pass round-robin
if need > 0 {
for need > 0 {
for _, g := range groups {
if need == 0 {
break
}
if result[g.name] < g.length {
result[g.name]++
need--
}
}
if need > 0 {
break
}
}
}
} else if sumTargets > maxTotal {
over := sumTargets - maxTotal
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac < remainders[j].frac })
for _, r := range remainders {
if over == 0 {
break
}
g := groups[r.idx]
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
for over > 0 {
for _, g := range groups {
if over == 0 {
break
}
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
break
}
}
}
}
return result
}
func downsampleModelRecords(in []models.Record, k int) []models.Record {
n := len(in)
if k <= 0 || n == 0 {
return []models.Record{}
}
if k >= n {
return in
}
out := make([]models.Record, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
func downsampleFlatRecords(in []flatRecord, k int) []flatRecord {
n := len(in)
if k <= 0 || n == 0 {
return []flatRecord{}
}
if k >= n {
return in
}
out := make([]flatRecord, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
// flatRecord is a projection used when load_type is specified.
type flatRecord struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu *float32 `json:"cpu,omitempty"`
Gpu *float32 `json:"gpu,omitempty"`
Ram *int64 `json:"ram,omitempty"`
RamTotal *int64 `json:"ram_total,omitempty"`
Swap *int64 `json:"swap,omitempty"`
SwapTotal *int64 `json:"swap_total,omitempty"`
Load *float32 `json:"load,omitempty"`
Temp *float32 `json:"temp,omitempty"`
Disk *int64 `json:"disk,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetIn *int64 `json:"net_in,omitempty"`
NetOut *int64 `json:"net_out,omitempty"`
NetTotalUp *int64 `json:"net_total_up,omitempty"`
NetTotalDown *int64 `json:"net_total_down,omitempty"`
Process *int `json:"process,omitempty"`
Connections *int `json:"connections,omitempty"`
ConnectionsUdp *int `json:"connections_udp,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
}
func filterRecordsByLoadType(recs []models.Record, loadType string) []flatRecord {
out := make([]flatRecord, 0, len(recs))
for _, r := range recs {
fr := flatRecord{Client: r.Client, Time: r.Time}
// always include uptime when present
//if r.Uptime != 0 {
// v := r.Uptime
// fr.Uptime = &v
//}
switch loadType {
case "cpu":
v := r.Cpu
fr.Cpu = &v
case "gpu":
v := r.Gpu
fr.Gpu = &v
case "ram":
v := r.Ram
fr.Ram = &v
vt := r.RamTotal
fr.RamTotal = &vt
case "swap":
v := r.Swap
fr.Swap = &v
vt := r.SwapTotal
fr.SwapTotal = &vt
case "load":
v := r.Load
fr.Load = &v
case "temp":
v := r.Temp
fr.Temp = &v
case "disk":
v := r.Disk
fr.Disk = &v
vt := r.DiskTotal
fr.DiskTotal = &vt
case "network":
vi := r.NetIn
vo := r.NetOut
vtu := r.NetTotalUp
vtd := r.NetTotalDown
fr.NetIn = &vi
fr.NetOut = &vo
fr.NetTotalUp = &vtu
fr.NetTotalDown = &vtd
case "process":
v := r.Process
fr.Process = &v
case "connections":
v := r.Connections
fr.Connections = &v
vu := r.ConnectionsUdp
fr.ConnectionsUdp = &vu
default:
// unknown type: fallback to all fields as a full record would be returned elsewhere
v := r.Cpu
fr.Cpu = &v
}
out = append(out, fr)
}
return out
}
+15
View File
@@ -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
}))
}
+48
View File
@@ -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()
}
}
+107
View File
@@ -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
}
+105
View File
@@ -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()
}
}
+89
View File
@@ -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, "")
}
+150
View File
@@ -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:": ""})
}
+28
View File
@@ -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)
}
+210
View File
@@ -0,0 +1,210 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/dbcore"
)
// copyFile 复制单个文件到目标路径(会确保父目录存在)
func copyFile(srcPath, destPath string) error {
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory: %v", err)
}
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file: %v", err)
}
defer src.Close()
dest, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %v", err)
}
defer dest.Close()
if _, err = io.Copy(dest, src); err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
return nil
}
// copyDataToTempExcludingDB 将 ./data 下除了 .db/.db-wal/.db-shm 之外的所有文件复制到临时目录
func copyDataToTempExcludingDB(tempDir string) error {
dataRoot := "./data"
// 如果 data 目录不存在,视为无文件可复制
if stat, err := os.Stat(dataRoot); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat data dir: %v", err)
} else if !stat.IsDir() {
return fmt.Errorf("%s is not a directory", dataRoot)
}
return filepath.Walk(dataRoot, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataRoot, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
// 跳过数据库相关文件
name := info.Name()
if strings.HasSuffix(strings.ToLower(name), ".db") ||
strings.HasSuffix(strings.ToLower(name), ".db-wal") ||
strings.HasSuffix(strings.ToLower(name), ".db-shm") {
return nil
}
dst := filepath.Join(tempDir, rel)
if info.IsDir() {
return os.MkdirAll(dst, 0o755)
}
return copyFile(p, dst)
})
}
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径
func backupSQLiteTo(destDBPath string) error {
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory for db: %v", err)
}
db := dbcore.GetDBInstance()
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database connection: %v", err)
}
safePath := strings.ReplaceAll(destDBPath, "'", "''")
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
}
return nil
}
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
// 1) 创建临时目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
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 {
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 := conf.Conf.Database.DatabaseFile
if conf.Conf.Database.DatabaseType == "sqlite" || conf.Conf.Database.DatabaseType == "" {
if err := backupSQLiteTo(destDB); err != nil {
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 {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
// 4) 开始写出 ZIP(以临时目录为根)
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
// 写入临时目录里的内容
err = filepath.Walk(tempDir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(tempDir, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
// zip 内路径统一正斜杠
zipPath := filepath.ToSlash(rel)
if info.IsDir() {
_, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath + "/",
Method: zip.Deflate,
Modified: info.ModTime(),
})
return err
}
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
w, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath,
Method: zip.Deflate,
Modified: info.ModTime(),
})
if err != nil {
return err
}
_, err = io.Copy(w, f)
return err
})
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
// 5) 追加备份标记文件(放在 zip 根目录)
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
}
+98
View File
@@ -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
// }
+49
View File
@@ -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})
}
+63
View File
@@ -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(&notifications); 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(&notifications).Error
if err != nil {
resp.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
resp.RespondSuccess(c, notifications)
}
+93
View File
@@ -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"})
}
+80
View File
@@ -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)
}
+50
View File
@@ -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)
}
+80
View File
@@ -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)
}
+154
View File
@@ -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)
}
+50
View File
@@ -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)
}
@@ -13,19 +13,44 @@ 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/internal/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"
)
const (
maxThemeArchiveFiles = 10000
maxThemeFileSize = 128 << 20
maxThemeExtractedSize = 512 << 20
maxThemeManifestSize = 1 << 20
)
// UploadTheme 上传主题
func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
data, err := io.ReadAll(c.Request.Body)
if err != nil || len(data) == 0 {
resp.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
return
}
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
if err := os.WriteFile(tempFile, data, 0644); err != nil {
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 检查文件扩展名(这里假定上传的就是zip)
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
resp.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
return
}
// 解压ZIP文件并验证
themeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
resp.RespondSuccessMessage(c, "主题上传成功", themeInfo)
}
// ListThemes 列出所有主题
func ListThemes(c *gin.Context) {
@@ -33,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")
@@ -62,7 +78,7 @@ func ListThemes(c *gin.Context) {
}
}
api.RespondSuccess(c, themes)
resp.RespondSuccess(c, themes)
}
// DeleteTheme 删除主题
@@ -72,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 !isValidMarketShort(req.Short) {
api.RespondError(c, http.StatusBadRequest, "无效的主题名称")
resp.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
return
}
@@ -91,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 !isValidMarketShort(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 解压并验证主题
@@ -147,10 +157,6 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
}
defer r.Close()
if err := validateThemeArchive(r.File); err != nil {
return themeInfo, err
}
// 查找komari-theme.json文件
var themeConfigFile *zip.File
for _, f := range r.File {
@@ -171,20 +177,23 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
}
defer rc.Close()
configData, err := io.ReadAll(io.LimitReader(rc, maxThemeManifestSize+1))
configData, err := io.ReadAll(rc)
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if len(configData) > maxThemeManifestSize {
return themeInfo, fmt.Errorf("主题配置文件超过 %d 字节限制", maxThemeManifestSize)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
}
if err := validateThemeManifest(themeInfo); err != nil {
return themeInfo, err
// 验证必填字段
if themeInfo.Name == "" || themeInfo.Short == "" {
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
}
// 验证Short字段格式(只允许字母、数字、下划线、连字符)
if !isValidThemeShort(themeInfo.Short) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
// 创建主题目录
@@ -244,26 +253,6 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
return themeInfo, nil
}
func validateThemeArchive(files []*zip.File) error {
if len(files) > maxThemeArchiveFiles {
return fmt.Errorf("主题压缩包文件数量超过 %d 个限制", maxThemeArchiveFiles)
}
var total uint64
for _, file := range files {
if file.FileInfo().IsDir() {
continue
}
if file.UncompressedSize64 > maxThemeFileSize {
return fmt.Errorf("主题文件 %s 超过 %d 字节限制", file.Name, maxThemeFileSize)
}
total += file.UncompressedSize64
if total > maxThemeExtractedSize {
return fmt.Errorf("主题解压后总大小超过 %d 字节限制", maxThemeExtractedSize)
}
}
return nil
}
// loadThemeConfig 加载主题配置
func loadThemeConfig(configPath string) (models.Theme, error) {
var themeInfo models.Theme
@@ -280,19 +269,8 @@ func loadThemeConfig(configPath string) (models.Theme, error) {
return themeInfo, nil
}
func validateThemeManifest(themeInfo models.Theme) error {
if !models.IsLocalizedText(themeInfo.Name) || themeInfo.Short == "" {
return fmt.Errorf("主题配置缺少必填字段(name、short)")
}
if !isValidMarketShort(themeInfo.Short) {
return fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
return themeInfo.ValidateConfiguration()
}
// isValidMarketShort validates a market entry short name (shared by the
// theme and plugin markets).
func isValidMarketShort(short string) bool {
// isValidThemeShort 验证主题short字段格式
func isValidThemeShort(short string) bool {
if short == "" || short == "default" {
return false
}
@@ -307,8 +285,32 @@ func isValidMarketShort(short string) bool {
return true
}
func downloadThemeFromURL(rawURL string) ([]byte, error) {
return DownloadMarketURL(rawURL, marketDownloadMaxBytes)
// downloadThemeFromURL 从URL下载主题文件
func downloadThemeFromURL(url string) ([]byte, error) {
// 发送HTTP GET请求
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("下载主题文件失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
}
// 读取响应内容
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
}
// 检查文件大小
if len(data) == 0 {
return nil, errors.New("下载的主题文件为空")
}
return data, nil
}
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
@@ -429,13 +431,7 @@ func UpdateTheme(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 校验主题短名称,防止路径穿越(如 ../)访问工作目录外的文件
if !isValidMarketShort(req.Short) {
api.RespondError(c, http.StatusBadRequest, "无效的主题名称")
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
@@ -444,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
}
@@ -498,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
}
// 保存下载链接,稍后更新到主题配置中
@@ -519,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下载链接,以便将来可以获取最新版本
@@ -537,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
@@ -547,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
}
@@ -560,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)
@@ -568,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
}
@@ -580,182 +576,38 @@ 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()
if err := validateThemeArchive(r.File); err != nil {
return themeInfo, err
}
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(io.LimitReader(rc, maxThemeManifestSize+1))
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if len(configData) > maxThemeManifestSize {
return themeInfo, fmt.Errorf("主题配置文件超过 %d 字节限制", maxThemeManifestSize)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
}
if err := validateThemeManifest(themeInfo); 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
}
var req map[string]any
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
}
@@ -763,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)
}
+46
View File
@@ -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)
}
+18
View File
@@ -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)
}
+40
View File
@@ -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})
}
+125
View File
@@ -0,0 +1,125 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
)
// 只有一个备份恢复操作在进行
var restoreMutex sync.Mutex
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
resp.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
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") {
resp.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
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 {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
// 将上传的文件内容复制到临时文件
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
// 基础校验:检查是否包含标记文件
if zr, err := zip.OpenReader(tempFilePath); err == nil {
hasMarkup := false
for _, f := range zr.File {
if f.Name == "komari-backup-markup" {
hasMarkup = true
break
}
}
zr.Close()
if !hasMarkup {
resp.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
} else {
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
// 将校验通过的临时文件移动到固定路径 ./data/backup.zip
finalPath := filepath.Join(".", "data", "backup.zip")
// 如存在旧文件,先删除
_ = os.Remove(finalPath)
if err := os.Rename(tempFilePath, finalPath); err != nil {
// fallback:拷贝
in, err2 := os.Open(tempFilePath)
if err2 != nil {
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 {
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()
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
return
}
out.Close()
}
// 返回:已保存备份,重启后将自动恢复
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
"path": "./data/backup.zip",
})
go func() {
log.Println("Backup uploaded, restarting service in 2 seconds to apply on startup...")
time.Sleep(2 * time.Second)
os.Exit(0)
}()
}
+47
View File
@@ -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})
}
+231
View File
@@ -0,0 +1,231 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/gorilla/websocket"
"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
)
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Println("Failed to read request body:", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
var data map[string]interface{}
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Save report to database
var report common.Report
err = json.Unmarshal(bodyBytes, &report)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
ws.SetLatestReport(report.UUID, &report)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
}
func WebSocketReport(c *gin.Context) {
// 升级ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // 被控
},
}
// Upgrade the HTTP connection to a WebSocket connection
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
conn := ws.NewSafeConn(unsafeConn)
defer conn.Close()
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
return
}
// 第一次数据拿token
data := map[string]interface{}{}
err = json.Unmarshal(message, &data)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
// it should ok,token was verfied in the middleware
token := ""
var errMsg string
// 优先检查查询参数中的 token
token = c.Query("token")
// 如果 token 为空,返回错误
if token == "" {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
uuid, err := clients.GetClientUUIDByToken(token)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
// 接受新连接,并处理旧连接
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
go oldConn.Close()
}
ws.SetConnectedClients(uuid, conn)
//log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
ws.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
event.Trigger(eventType.ClientWebsocketConnected, event.M{
"client": uuid,
"online": len(ws.GetConnectedClients()),
"connID": conn.ID,
})
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
for {
conn.SetReadDeadline(time.Now().Add(readWait))
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Client %s connection error: %v", uuid, err)
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
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 *ws.SafeConn, message []byte, uuid string) {
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err := json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
return
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
return
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
func SaveClientReport(uuid string, report common.Report) error {
reports, _ := vars.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
reports = append(reports.([]common.Report), report)
vars.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
+43
View File
@@ -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,
})
}
+48
View File
@@ -0,0 +1,48 @@
package client
import (
"net/http"
"github.com/gin-gonic/gin"
"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 := 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 !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 // 被控
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
terminal.TerminalSessionsMutex.Lock()
if session.Browser != nil {
session.Browser.Close()
}
delete(terminal.TerminalSessions, session_id)
terminal.TerminalSessionsMutex.Unlock()
return
}
session.Agent = conn
conn.SetCloseHandler(func(code int, text string) error {
delete(terminal.TerminalSessions, session_id)
// 通知 Browser 关闭终端连接
if session.Browser != nil {
session.Browser.Close()
}
return nil
})
go terminal.ForwardTerminal(session_id)
}
+92
View File
@@ -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"})
}
+200
View File
@@ -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)
}
}
}
+46
View File
@@ -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)
}
+106
View File
@@ -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")
}
})
}
}
+27
View File
@@ -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 != ""})
}
+95
View File
@@ -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()
}
+40
View File
@@ -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)
}
+153
View File
@@ -0,0 +1,153 @@
package api_v1
import (
"fmt"
"slices"
"github.com/gin-gonic/gin"
"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) {
cfg, _ := conf.GetWithV1Format()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
}
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
c.Redirect(302, authURL)
}
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
// 获取当前OAuth提供商名称
providerName := oauth.CurrentProvider().GetName()
providersSkipStateCheck := []string{"qq"}
if slices.Contains(providersSkipStateCheck, providerName) {
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
// 但我们仍然需要验证state的存在性(不能是空的)
if state == "" {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
} else {
// 对于其他提供商,严格验证state匹配
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
}
queries := make(map[string]string)
for key, values := range c.Request.URL.Query() {
if len(values) > 0 {
queries[key] = values[0]
}
}
oidcUser, err := oauth.CurrentProvider().OnCallback(c, state, queries, utils.GetCallbackURL(c))
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
}
// ID作为SSO ID
sso_id := fmt.Sprintf("%s_%s", oauth.CurrentProvider().GetName(), oidcUser.UserId)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
uuid, _ := c.Cookie("binding_external_account")
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
if uuid != "" {
// 绑定外部账号
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil || user.UUID != uuid {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
err = accounts.BindingExternalAccount(user.UUID, sso_id)
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
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
}
// 尝试获取用户
user, err := accounts.GetUserBySSO(sso_id)
if err != nil {
c.JSON(401, gin.H{
"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, 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并返回
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")
}
+16
View File
@@ -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)
}
+447
View File
@@ -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,
})
}
+38
View File
@@ -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)
}
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff. Show more