Compare commits

..
74 Commits
Author SHA1 Message Date
Akizon 862657ea86 Merge pull request #83 from komari-monitor/dev
feat: 在获取的任务中添加间隔时间字段
2025-06-30 18:38:06 +08:00
Akizon77 70b519a345 feat: 在获取的任务中添加间隔时间字段 2025-06-30 18:09:31 +08:00
Akizon 6600e4014d Merge pull request #82 from komari-monitor/dev
feat: #53 通知方式兼容SMTP邮件、支持下载备份
2025-06-29 23:50:00 +08:00
Akizon77 b8a6f18a77 feat: 支持下载备份 2025-06-29 23:29:19 +08:00
Akizon77 03e08cdfbb fix: 只有在通知方式变动时重载MessageSenderProvider 2025-06-29 22:30:28 +08:00
Akizon77 b07f7345ee feat: 通知方式兼容SMTP邮件 2025-06-29 22:28:04 +08:00
Akizon77 e85996178a refactor: 抽象消息发送接口 2025-06-29 22:17:35 +08:00
Akizon77 5547fcd067 ci: 发布main分支的docker镜像,修正release 2025-06-29 21:54:41 +08:00
Akizon aef4344997 Merge pull request #81 from komari-monitor/dev
fix: #79 GeoIP无法更新
feat: IP查询兼容 ipinfo.io
2025-06-29 21:44:43 +08:00
Akizon77 061197cd77 ci: 发布到测试环境不显示部分日志 2025-06-29 21:40:14 +08:00
Akizon77 c00d1d0cb2 ci: 发布docker镜像时预编译二进制 2025-06-29 21:36:30 +08:00
Akizon77 b6a7511e33 fix: #79 GeoIP无法更新 2025-06-29 01:31:54 +08:00
Akizon77 13450a50eb feat: IP查询兼容 ipinfo.io 2025-06-28 21:04:37 +08:00
Akizon eb883ccc6f Merge pull request #78 from komari-monitor/dev
feat: 到期提醒显示具体还剩多少天
2025-06-28 20:20:43 +08:00
Akizon77 2ae9b3516c fix: MaxMind 数据库 initialize 方法需要在解锁后调用,以避免死锁 2025-06-28 20:15:31 +08:00
Akizon77 7dc2a0294e feat: 到期提醒显示具体还剩多少天 2025-06-28 20:02:32 +08:00
Akizon f97b91c19c Merge pull request #77 from komari-monitor/dev
feat: 添加过期通知功能,支持配置过期前通知天数
2025-06-27 23:10:54 +08:00
Akizon77 bfac98dc67 feat: 添加过期通知功能,支持配置过期前通知天数 2025-06-27 22:28:42 +08:00
Akizon77 0f0f9f5985 feat: 添加记录删除功能,支持在记录禁用时清除所有记录 2025-06-27 22:13:24 +08:00
Akizon efebe66518 Merge pull request #76 from komari-monitor/dev
feat: 添加对ip-api和geojs服务的支持
2025-06-27 21:38:10 +08:00
Akizon77 37a05e8f3d fix: 更新时间条件,调整为四小时之前的记录查询 2025-06-27 21:36:04 +08:00
Akizon77 7473c438c2 feat: 订阅配置变化更新geoip配置 2025-06-27 21:22:50 +08:00
Akizon77 6e4e6d0653 feat: 添加对ip-api和geojs服务的支持,更新GeoIP配置 2025-06-27 21:20:31 +08:00
Akizon77 c09bec5c20 refactor: 更新GeoIP服务实现 2025-06-27 21:05:59 +08:00
Akizon77 14ce2b8f53 fix: 调整高位百分比和删除旧数据的时间条件 2025-06-27 20:26:49 +08:00
Akizon 8ecb6aa7aa Merge pull request #75 from komari-monitor/dev
ci: docker发布
2025-06-27 17:36:26 +08:00
Akizon77 cedc144173 ci: docker发布 2025-06-27 17:34:26 +08:00
Akizon 434e09ae95 Merge pull request #74 from komari-monitor/dev
refactor: 将time.Time 迁移到UTCTime
2025-06-27 13:55:34 +08:00
Akizon77 06ffc44530 refactor: 将time.Time 迁移到UTCTime 2025-06-27 13:53:03 +08:00
Akizon a7c24e2323 Merge pull request #72 from komari-monitor/dev
ci: 限定Matrix
2025-06-25 23:15:26 +08:00
Akizon77 726cfa513e ci: 限定Matrix 2025-06-25 23:14:33 +08:00
Akizon 62a1317d88 Merge pull request #71 from komari-monitor/dev
ci: 使用Zig作为C编译器
2025-06-25 23:12:20 +08:00
Akizon77 1221feae45 ci: 使用Zig作为C编译器 2025-06-25 23:11:53 +08:00
Akizon 884c5686bd Merge pull request #70 from komari-monitor/dev
fix: #69  (概率)离线通知未按预期工作
2025-06-25 19:09:46 +08:00
Akizon77 9b82a19528 fix: #69 (概率)离线通知未按预期工作 2025-06-25 19:09:04 +08:00
Akizon 2db4dfb942 Merge pull request #67 from komari-monitor/dev
fix: 修复了每次建立连接都会通知客户端上线的问题
2025-06-24 14:52:07 +08:00
Akizon77 09df09c29a fix: 修复了每次建立连接都会通知客户端上线的问题 2025-06-24 14:51:27 +08:00
Akizon77 215fdc822c feat: 使用go-cache替代安全映射
ci: main自动发布附件
2025-06-24 14:36:30 +08:00
Akizon 1572b92751 Merge pull request #65 from komari-monitor/dev
fix: #57 api.Records引入线程安全的泛型映射
2025-06-23 23:39:40 +08:00
Akizon77 8da28d6b95 fix: #57 api.Records引入线程安全的泛型映射 2025-06-23 23:09:21 +08:00
Akizon 6f24268683 Merge pull request #63 from komari-monitor/dev
feat: 不再自动清除ping记录,重命名Telegram测试为发送消息
2025-06-22 23:17:46 +08:00
Akizon77 abe9de2bd1 feat: 在获取Ping记录特定任务过滤 2025-06-22 22:53:13 +08:00
Akizon77 342aacd060 fix: 只有订阅了ping task的节点能获取相应的task信息 2025-06-22 22:06:18 +08:00
Akizon77 a95d621bdb feat: 不再自动清除ping记录,重命名Telegram测试为发送消息 2025-06-22 21:53:19 +08:00
Akizon77 68a8f01b32 fix: 修正获取记录时的时间范围和分组逻辑 2025-06-22 19:33:43 +08:00
Akizon ca07f1b904 Merge pull request #61 from komari-monitor/dev
feat: 增加Ping任务管理功能
2025-06-22 15:05:43 +08:00
Akizon77 1aa76be031 feat: 在获取Ping记录时添加任务列表响应 2025-06-22 15:04:22 +08:00
Akizon77 38be68455c feat: 更新获取Ping记录的API路由 2025-06-22 14:56:58 +08:00
Akizon77 2f3cfd5387 fix: 修正获取4小时内记录的时间范围,增加10分钟偏移 2025-06-22 14:54:57 +08:00
Akizon77 d3a99c0ff4 feat: 获取长期记录时,4小时内的数据按15分钟分组获取最近记录 2025-06-22 14:50:08 +08:00
Akizon77 b4d5a40ca4 feat: 更新AddPingTask和EditPingTask函数以支持名称字段 2025-06-21 21:43:23 +08:00
Akizon77 356e71ad22 feat: 添加PingTask的名称字段 2025-06-21 19:27:19 +08:00
Akizon77 d6b58f6eed feat: 增加Ping任务管理功能 2025-06-21 16:47:39 +08:00
Akizon77 785f448baf wip: ping task
fix: Cannot add a column with non-constant default
2025-06-20 22:40:03 +08:00
Akizon77 194987f66a fix: 移除冷却时间检查 2025-06-20 22:28:30 +08:00
Akizon77 bbff63bd7d fix: #57 自动创建data文件夹 2025-06-20 22:19:33 +08:00
Akizon f654b71b31 Merge pull request #58 from komari-monitor/dev
feat: #57 增加connectedClients、latestReport线程安全
2025-06-20 22:07:15 +08:00
Akizon77 b85d1d1a48 feat: #57 增加connectedClients、latestReport线程安全 2025-06-20 21:57:30 +08:00
Akizon d669d2862e Update README.md 2025-06-19 21:48:33 +08:00
Akizon e6e71716c8 Merge pull request #55 from komari-monitor/dev
feat: 可配置的历史记录保存时长
2025-06-19 15:08:27 +08:00
Akizon77 5022148fd9 feat: 可配置的历史记录保存时长
wip: PING 检测
2025-06-19 15:07:17 +08:00
Akizon77 f069edc026 Merge branch 'dev' of https://github.com/komari-monitor/komari into dev 2025-06-19 14:31:06 +08:00
Akizon77 65cf7b30c3 wip: Ping、TCPing、HTTPing 2025-06-19 14:31:03 +08:00
Akizon 5110b42f0a Update todo.md 2025-06-19 12:54:31 +08:00
Akizon 92475f7331 Merge pull request #54 from komari-monitor/dev
feat: 补上站点名称和描述的坑
2025-06-18 23:54:57 +08:00
Akizon77 12ad38fc78 feat: 补上站点名称和描述的坑 2025-06-18 23:54:01 +08:00
Akizon77 dbf6e24d68 fix: 修复在保存客户端报告时的空UUID处理逻辑 2025-06-18 15:14:37 +08:00
Akizon 19d42a41dd Merge pull request #52 from komari-monitor/dev
refactor: 重构压缩数据库的方法
2025-06-17 17:53:29 +08:00
Akizon77 573fbdb6d8 feat: 下放获取历史记录的权限 2025-06-17 17:49:24 +08:00
Akizon77 52a7024e82 feat: 获取历史负载记录 2025-06-17 17:38:42 +08:00
Akizon77 600ae2e36f refactor: 重构压缩数据库的方法 2025-06-17 17:28:58 +08:00
Akizon d7b744a01a Merge pull request #50 from komari-monitor/main
同步
2025-06-16 00:34:57 +08:00
Akizon a3d4f8ca6a Merge pull request #49 from komari-monitor/dev
feat: 在启动服务器时根据版本哈希设置Gin模式
2025-06-16 00:34:17 +08:00
Akizon77 18ad4fe9de feat: 在启动服务器时根据版本哈希设置Gin模式 2025-06-16 00:33:38 +08:00
66 changed files with 2810 additions and 755 deletions

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
name: Build Binaries on Main Push
on:
push:
branches:
- main
jobs:
build-binaries:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
run: |
sudo apt-get update # Update apt package lists
sudo apt-get install -y build-essential curl
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
# Append the appropriate executable extension based on the OS
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
+9 -26
View File
@@ -25,35 +25,18 @@ jobs:
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
});
// Keep packages with 'dev' or 'latest' tags, plus the 10 most recent non-dev, non-latest versions
const keepVersions = [
// Keep packages with 'dev' tag
...packages.filter(pkg => pkg.metadata.container.tags.includes('dev')),
// Keep packages with 'latest' tag, excluding those already included with 'dev'
...packages.filter(
pkg =>
pkg.metadata.container.tags.includes('latest') &&
!pkg.metadata.container.tags.includes('dev')
),
// Keep the 10 most recent non-dev, non-latest packages
...packages
.filter(
pkg =>
!pkg.metadata.container.tags.includes('dev') &&
!pkg.metadata.container.tags.includes('latest')
)
.slice(0, 10),
];
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
const shaOnlyVersions = packages.filter(pkg => {
const tags = pkg.metadata.container.tags;
return tags.length === 1 && tags[0].match(/^sha-[0-9a-f]{7}$/);
});
// Identify versions to delete (not in keepVersions)
const deleteVersions = packages.filter(pkg => !keepVersions.includes(pkg));
// Delete the versions
for (const version of deleteVersions) {
// Delete the SHA-only versions
for (const version of shaOnlyVersions) {
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
package_version_id: version.id
});
console.log(`Deleted package version ${version.id}`);
}
console.log(`Deleted package version ${version.id} with SHA tag ${version.metadata.container.tags[0]}`);
}
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- name: Add host to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to production
run: |
+51 -5
View File
@@ -1,4 +1,4 @@
name: Docker Build and Publish
name: Publish Docker Image
on:
push:
@@ -11,7 +11,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
@@ -21,6 +21,54 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/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/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -42,8 +90,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
VERSION_HASH=${{ github.sha }}
+98
View File
@@ -0,0 +1,98 @@
name: Publish Docker Image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-multi-arch:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "23"
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
run: |
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+41 -66
View File
@@ -1,4 +1,4 @@
name: Build and Attach Binaries to Release
name: Attach Release Binaries
on:
release:
@@ -19,26 +19,43 @@ jobs:
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
gcc: x86_64-w64-mingw32-gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
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
gcc: gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
gcc: aarch64-linux-gnu-gcc
CGO_CFLAGS: '-pthread'
CGO_LDFLAGS: '-pthread'
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: arm64
goarch: riscv64
steps:
- name: Checkout code
@@ -64,81 +81,39 @@ jobs:
with:
go-version: "1.23"
- name: Install cross-compiler
- name: Install Zig cross-compiler toolchain
run: |
sudo apt-get update
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
sudo apt-get install gcc-aarch64-linux-gnu
fi
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install mingw-w64
fi
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install wget xz-utils
mkdir -p /tmp/mingw64
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
fi
sudo apt-get update # Update apt package lists
sudo apt-get install -y build-essential curl
wget https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz
sudo tar -C /usr/local -Jxf zig-x86_64-linux-0.14.1.tar.xz
echo "PATH=$PATH:/usr/local/zig-x86_64-linux-0.14.1" >> $GITHUB_ENV
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: ${{ matrix.gcc }}
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Define the binary name for upload
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
fi
# Upload the binary as a release asset using GitHub CLI
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
shell: bash
build-and-push-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
+9 -28
View File
@@ -1,34 +1,16 @@
FROM node:23-alpine AS frontend
WORKDIR /web
RUN apk add --no-cache git
RUN git clone https://github.com/komari-monitor/komari-web .
RUN npm install
RUN npm run build
FROM golang:1.24 AS builder
WORKDIR /app
# RUN apt-get update && apt-get install -y musl-tools
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /web/dist ./public/dist
ENV CGO_ENABLED=1
ARG VERSION=unknown
ARG VERSION_HASH=unknown
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
# FROM scratch
FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/komari .
# Docker buildx 会在构建时自动填充这些变量
ARG TARGETOS
ARG TARGETARCH
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
# 数据库配置环境变量(可以在运行时覆盖)
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
@@ -40,5 +22,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
# 使用环境变量启动服务
CMD ["/app/komari","server"]
CMD ["/app/komari", "server"]
+1
View File
@@ -85,6 +85,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
## 鸣谢
- 感谢我自己能这么闲
- 广告位招租喵
## 引用
- [gorm.io](https://gorm.io/)
+44 -12
View File
@@ -1,6 +1,7 @@
package api
import (
"log"
"net/http"
"sync"
"time"
@@ -8,6 +9,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/patrickmn/go-cache"
"strconv"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
@@ -15,7 +19,7 @@ import (
)
var (
Records map[string][]common.Report = make(map[string][]common.Report)
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
@@ -29,25 +33,53 @@ var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute * 1).Unix()
record := []models.Record{}
// 遍历所有的客户端记录
for uuid, reports := range Records {
// 删除一分钟前的记录
filtered := reports[:0]
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
if uuid == "" {
continue
}
reports, ok := x.Object.([]common.Report)
if !ok {
log.Printf("Invalid report type for UUID %s", uuid)
continue
}
// 过滤一分钟前的记录
var filtered []common.Report
for _, r := range reports {
if r.UpdatedAt.Unix() >= lastMinute {
filtered = append(filtered, r)
}
}
Records[uuid] = filtered
r := utils.AverageReport(uuid, time.Now(), filtered)
record = append(record, r)
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered)
records = append(records, r)
}
}
// 批量插入数据库前去重(client与time共同构成唯一键)
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
key := rec.Client + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
unique[key] = rec
}
var deduped []models.Record
for _, rec := range unique {
deduped = append(deduped, rec)
}
db := dbcore.GetDBInstance()
err := db.Model(&models.Record{}).Where("client = ?", uuid).Create(record).Error
if err != nil {
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
+130
View File
@@ -0,0 +1,130 @@
package admin
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"log"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
)
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 获取在 zip 包内的相对路径
relPath, err := filepath.Rel("./data", filePath)
if err != nil {
return err
}
// 跳过根目录本身,只打包内容
if relPath == "." {
return nil
}
// zip 内路径统一用正斜杠
zipPath := filepath.ToSlash(relPath)
if info.IsDir() {
// 在 zip 中创建目录项
_, err = zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath + "/",
Method: zip.Deflate, // zip.Store 不压缩
Modified: info.ModTime(),
})
return err
}
// 普通文件,写入 zip
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: zipPath,
Method: zip.Deflate,
Modified: info.ModTime(),
})
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data folder: %v", err))
return
}
// 如果数据库文件不在 ./data 目录下,单独添加
dbFilePath := flags.DatabaseFile
dbFileName := filepath.Base(dbFilePath)
if !strings.HasPrefix(filepath.Clean(dbFilePath), filepath.Clean("./data")) {
// 不在 ./data,单独添加
dbInfo, err := os.Stat(dbFilePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
// 数据库文件不存在不影响主流程
return
}
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
if !dbInfo.IsDir() { // 确保是文件
file, err := os.Open(dbFilePath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
return
}
defer file.Close()
// 直接用文件名放在 zip 根目录
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: dbFileName,
Method: zip.Deflate,
Modified: dbInfo.ModTime(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating zip entry for database file: %v", err))
return
}
_, err = io.Copy(writer, file)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing database file to zip: %v", err))
return
}
}
} else {
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
}
// zipWriter.Close() 由 defer 自动调用
}
+2 -2
View File
@@ -25,7 +25,7 @@ func Exec(c *gin.Context) {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.ConnectedClients {
for uuid := range ws.GetConnectedClients() {
if contain(req.Clients, uuid) {
onlineClients = append(onlineClients, uuid)
} else {
@@ -53,7 +53,7 @@ func Exec(c *gin.Context) {
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.ConnectedClients[uuid]
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
} else {
+1
View File
@@ -0,0 +1 @@
package notification
+3 -3
View File
@@ -79,15 +79,15 @@ func EditOfflineNotification(c *gin.Context) {
api.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 || noti.Cooldown <= 0 {
api.RespondError(c, 400, "GracePeriod and Cooldown must be positive integers")
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "cooldown", "grace_period"}),
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
}).
Select("*").
Create(notifications).Error
+80
View File
@@ -0,0 +1,80 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
// POST body: clients []string, target, task_type string, interval int
func AddPingTask(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name" binding:"required"`
Target string `json:"target" binding:"required"`
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeletePingTask(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditPingTask(c *gin.Context) {
var req struct {
Tasks []*models.PingTask `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
}
+10
View File
@@ -7,6 +7,8 @@ import (
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gin-gonic/gin"
)
@@ -69,3 +71,11 @@ func contains(slice []string, item string) bool {
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
+5 -5
View File
@@ -7,14 +7,14 @@ import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/telegram"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestTelegram(c *gin.Context) {
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := telegram.SendTextMessage(message)
err := messageSender.SendTextMessage(message, "Komari Test Notification")
if err != nil {
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
@@ -38,7 +38,7 @@ func TestGeoIp(c *gin.Context) {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
GeoIpRecord, err := geoip.CurrentProvider.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateGeoIpDatabase(); err != nil {
if err := geoip.CurrentProvider.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
+58 -16
View File
@@ -15,8 +15,11 @@ import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
"github.com/patrickmn/go-cache"
)
func UploadReport(c *gin.Context) {
@@ -48,7 +51,7 @@ func UploadReport(c *gin.Context) {
}
// Update report with method and token
ws.LatestReport[report.UUID] = &report
ws.GetLatestReport()[report.UUID] = &report
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -66,11 +69,12 @@ func WebSocketReport(c *gin.Context) {
},
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
conn := ws.NewSafeConn(unsafeConn)
defer conn.Close()
_, message, err := conn.ReadMessage()
@@ -106,14 +110,14 @@ func WebSocketReport(c *gin.Context) {
}
// 只允许一个客户端的连接
if _, exists := ws.ConnectedClients[uuid]; exists {
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
}
ws.ConnectedClients[uuid] = conn
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
defer func() {
delete(ws.ConnectedClients, uuid)
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
}()
@@ -123,29 +127,67 @@ func WebSocketReport(c *gin.Context) {
break
}
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
break
type MessageType struct {
Type string `json:"type"`
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
var msgType MessageType
err = json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
ws.LatestReport[uuid] = &report
}
}
func SaveClientReport(uuid string, report common.Report) error {
if api.Records[uuid] == nil {
api.Records[uuid] = []common.Report{}
reports, _ := api.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
api.Records[uuid] = append(api.Records[uuid], report)
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
+4 -4
View File
@@ -29,15 +29,15 @@ func UploadBasicInfo(c *gin.Context) {
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
ip4_record, _ := geoip.CurrentProvider.GetGeoInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
ip6_record, _ := geoip.CurrentProvider.GetGeoInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
}
}
}
+2 -1
View File
@@ -10,5 +10,6 @@ func GetClientRecentRecords(c *gin.Context) {
RespondError(c, 400, "UUID is required")
return
}
RespondSuccess(c, Records[uuid])
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
+10 -7
View File
@@ -13,13 +13,16 @@ func GetPublicSettings(c *gin.Context) {
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
})
}
+110
View File
@@ -0,0 +1,110 @@
package record
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
}
// GET query: uuid string, hours int
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
response := gin.H{
"records": records,
"count": len(records),
}
if len(records) > 0 {
// 获取当前属于该 uuid 的 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
taskIdSet := make(map[uint]struct{})
for _, r := range records {
taskIdSet[r.TaskId] = struct{}{}
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
// 只保留分配给该 uuid 的任务
found := false
for _, client := range t.Clients {
if client == uuid {
found = true
break
}
}
if !found {
continue
}
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
})
}
}
response["tasks"] = tasksList
}
api.RespondSuccess(c, response)
}
+2 -2
View File
@@ -62,7 +62,7 @@ func RequestTerminal(c *gin.Context) {
return nil
})
if ws.ConnectedClients[uuid] == nil {
if ws.GetConnectedClients()[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.Close()
TerminalSessionsMutex.Lock()
@@ -70,7 +70,7 @@ func RequestTerminal(c *gin.Context) {
TerminalSessionsMutex.Unlock()
return
}
err = ws.ConnectedClients[uuid].WriteJSON(gin.H{
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
})
+51 -9
View File
@@ -18,6 +18,7 @@ import (
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
@@ -27,7 +28,10 @@ import (
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
u_notification "github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
@@ -41,9 +45,17 @@ var ServerCmd = &cobra.Command{
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
r := gin.Default()
@@ -55,6 +67,12 @@ var ServerCmd = &cobra.Command{
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
go geoip.InitGeoIp()
}
if event.New.NotificationMethod != event.Old.NotificationMethod {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
@@ -82,7 +100,7 @@ var ServerCmd = &cobra.Command{
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
@@ -94,6 +112,9 @@ var ServerCmd = &cobra.Command{
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
// #region Agent
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
@@ -102,14 +123,15 @@ var ServerCmd = &cobra.Command{
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/telegram", test.TestTelegram)
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
@@ -150,6 +172,7 @@ var ServerCmd = &cobra.Command{
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
@@ -190,12 +213,22 @@ var ServerCmd = &cobra.Command{
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// 静态文件服务
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
@@ -254,20 +287,29 @@ func InitDatabase() {
}
}
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
ticker := time.NewTicker(time.Minute * 30)
ticker1 := time.NewTicker(60 * time.Second)
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go u_notification.CheckExpireScheduledWork()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * 24 * 30))
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
logOperation.RemoveOldLogs()
case <-ticker1.C:
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
}
}
+2 -2
View File
@@ -100,8 +100,8 @@ func CreateDefaultAdminAccount() (username, passwd string, err error) {
Username: username,
Passwd: hashedPassword,
SSOID: "",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&user).Error
+3 -3
View File
@@ -27,11 +27,11 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().Add(time.Duration(expires) * time.Second),
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now(),
LatestOnline: models.FromTime(time.Now()),
}
err := db.Create(&sessionRecord).Error
@@ -50,7 +50,7 @@ func GetSession(session string) (uuid string, err error) {
return "", err
}
if time.Now().After(sessionRecord.Expires) {
if time.Now().After(sessionRecord.Expires.ToTime()) {
// 会话已过期,删除它
_ = DeleteSession(session)
return "", errors.New("session expired")
+4 -4
View File
@@ -180,8 +180,8 @@ func CreateClient() (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
@@ -202,8 +202,8 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
+1 -1
View File
@@ -57,7 +57,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Record := models.Record{
Client: clientUUID,
Time: time.Now(),
Time: models.FromTime(time.Now()),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Ram: report.Ram.Used,
+15 -4
View File
@@ -22,9 +22,12 @@ func Get() (models.Config, error) {
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "mmdb",
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
GeoIpProvider: "ip-api",
EmailPort: 587,
EmailUseSSL: true,
EmailReceiver: "", // Default empty
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
@@ -41,7 +44,7 @@ func Save(cst models.Config) error {
oldConfig, _ := Get()
// Only one records
cst.ID = 1
cst.UpdatedAt = time.Now()
cst.UpdatedAt = models.FromTime(time.Now())
// Do not update CreatedAt
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("sitename",
@@ -53,6 +56,14 @@ func Save(cst models.Config) error {
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"email_enabled",
"email_host",
"email_port",
"email_username",
"email_password",
"email_sender",
"email_use_ssl",
"email_receiver",
"updated_at").
Updates(cst).Error; err != nil {
return err
+9 -38
View File
@@ -50,7 +50,7 @@ func mergeClientInfo(db *gorm.DB) {
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = info.ExpiredAt
client.ExpiredAt = models.FromTime(info.ExpiredAt)
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
@@ -82,43 +82,6 @@ var (
once sync.Once
)
// 初始化数据库
// 对于 SQLite:true 如果数据库文件存在,false 如果数据库文件不存在并被创建
// 对于 MySQL/其他数据库:总是返回 true
// func InitDatabase() bool {
// // 默认使用 SQLite 如果未指定类型
// if flags.DatabaseType == "" || flags.DatabaseType == "sqlite" {
// if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
// log.Printf("SQLite database file %q does not exist, creating...", flags.DatabaseFile)
// dbDir := filepath.Dir(flags.DatabaseFile)
// if dbDir != "" {
// if err := os.MkdirAll(dbDir, 0755); err != nil {
// log.Fatalf("Failed to create database file directory %q: %v", dbDir, err)
// }
// }
// file, err := os.Create(flags.DatabaseFile)
// if err != nil {
// log.Fatalf("Failed to create SQLite database file %q: %v", flags.DatabaseFile, err)
// }
// if err := file.Close(); err != nil {
// log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
// }
// return false
// } else if err != nil {
// log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
// }
// return true
// } else if flags.DatabaseType == "mysql" {
// // 对于 MySQL,我们不需要创建文件,只需检查连接信息是否有效
// log.Printf("Using MySQL database: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// return true
// } else {
// log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
// return false
// }
// }
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
@@ -171,10 +134,18 @@ func GetDBInstance() *gorm.DB {
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
+1 -1
View File
@@ -16,7 +16,7 @@ func Log(ip, uuid, message, msgType string) {
UUID: uuid,
Message: message,
MsgType: msgType,
Time: now,
Time: models.FromTime(now),
}
db.Create(logEntry)
}
+145
View File
@@ -0,0 +1,145 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// UTCTime 是一个自定义类型,用于在 GORM 中处理 time.Time。
// 它强制所有时间都以指定时区(或 UTC)的字符串格式存取。
type UTCTime time.Time
// Value 实现 driver.Valuer 接口,将 UTCTime 转换为数据库可以识别的 TEXT 类型。
// 所有时间在存储到数据库前,都转换为 UTC 并以标准格式存储。
func (t UTCTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil // GORM 默认会将零值时间存储为 NULL,这里保持一致
}
// 将时间转换为应用程序指定的时区,并格式化为不带时区信息的字符串。
// 这样数据库中存储的是 'YYYY-MM-DD HH:MM:SS.NNNNNNN' 格式。
// 注意:之前你遇到的格式是 "2025-06-14 23:51:17.3022328+08:00"
// 这种格式带有微秒和时区偏移。为了兼容旧数据读取,我们这里也用精确格式。
// 但存储时,为了避免时区混乱,建议统一为某个特定时区的纯时间戳字符串。
// 这里我建议统一存储为 UTC 时间的精确字符串。
// "2006-01-02 15:04:05.0000000" 这个布局对应不带时区后缀的纳秒精度。
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan 实现 sql.Scanner 接口,将数据库中的 TEXT 值扫描到 UTCTime。
// 所有时间字符串在读取时,都会被尝试解析为指定时区(或 UTC)的 time.Time 对象。
func (t *UTCTime) Scan(v interface{}) error {
if v == nil {
*t = UTCTime(time.Time{})
return nil
}
switch val := v.(type) {
case time.Time:
// If the driver already parsed it to time.Time, use it directly.
// Then convert to the application's desired location.
*t = UTCTime(val.In(GetAppLocation()))
return nil
case []byte:
// If it's still a byte slice (string), parse it.
timeStr := strings.TrimSpace(string(val))
if timeStr == "" {
*t = UTCTime(time.Time{})
return nil
}
var parsedTime time.Time
var err error
layouts := []string{
"2006-01-02 15:04:05.0000000-07:00", // Your observed precise format with timezone
"2006-01-02 15:04:05-07:00", // With seconds and timezone
time.RFC3339Nano, // RFC3339 with nanoseconds
time.RFC3339, // RFC3339
"2006-01-02 15:04:05.0000000", // Nanoseconds, no timezone
"2006-01-02 15:04:05", // Standard, no timezone
"2006-01-02", // Date only
}
for _, layout := range layouts {
// time.Parse handles strings that include timezone info correctly.
parsedTime, err = time.Parse(layout, timeStr)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("无法解析时间字符串 '%s' 为 UTCTime: %w", timeStr, err)
}
*t = UTCTime(parsedTime.In(GetAppLocation()))
return nil
default:
return fmt.Errorf("UTCTime scan source was not []byte or time.Time: %T (%v)", v, v)
}
}
// MarshalJSON implements json.Marshaler interface.
// This method defines how UTCTime should be serialized to JSON.
func (t UTCTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
// 如果是零值时间,返回 JSON null。
// 这样 API 返回的就是 "field": null,而不是 "field": {}。
return []byte("null"), nil
}
// 将时间转换为应用程序指定的时区,并格式化为 JSON 字符串。
// 使用双引号包裹,符合 JSON 字符串规范。
// 同样使用之前定义的精确格式,便于客户端解析。
formattedTime := time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000-07:00")
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime 将 UTCTime 转换为 Go 的 time.Time 类型。
func (t UTCTime) ToTime() time.Time {
return time.Time(t)
}
// FromTime 将 Go 的 time.Time 类型转换为 UTCTime。
func FromTime(t time.Time) UTCTime {
return UTCTime(t)
}
// Now returns the current time in the application's configured location.
func Now() UTCTime {
return UTCTime(time.Now().In(GetAppLocation()))
}
// AppLocation 用于存储应用程序全局使用的时区。
// 首次获取后会缓存,避免重复加载。
var (
appLocation *time.Location
locationOnce sync.Once
defaultLocation = time.UTC // 默认时区设为 UTC
)
// GetAppLocation 获取应用程序的全局时区。
// 优先从环境变量 "TZ" 获取,否则使用默认的 UTC。
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz != "" {
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone from environment variable TZ='%s', using default UTC. Error: %v\n", tz, err)
appLocation = defaultLocation
} else {
appLocation = loc
log.Printf("Info: Application timezone set from environment variable TZ='%s' (%s).\n", tz, appLocation.String())
}
} else {
appLocation = defaultLocation
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
}
})
return appLocation
}
+7 -9
View File
@@ -1,13 +1,11 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
+20 -7
View File
@@ -1,7 +1,5 @@
package models
import "time"
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
@@ -9,7 +7,7 @@ type Config struct {
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
@@ -19,12 +17,27 @@ type Config struct {
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
// Email 配置
EmailHost string `json:"email_host" gorm:"type:varchar(255)"` // 邮件服务器地址
EmailPort int `json:"email_port" gorm:"default:587"` // 邮件服务器端口,默认587
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"` // 邮箱用户名
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"` // 邮箱密码
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"` // 邮箱发送者地址
EmailUseSSL bool `json:"email_use_ssl" gorm:"default:true"` // 是否使用 SSL/TLS 连接,默认 true
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"` // 邮件接收者地址
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
CreatedAt time.Time
UpdatedAt time.Time
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt UTCTime
UpdatedAt UTCTime
}
+6 -8
View File
@@ -1,12 +1,10 @@
package models
import "time"
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time time.Time `json:"time" gorm:"autoCreateTime;not null"`
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time UTCTime `json:"time" gorm:"autoCreateTime;not null"`
}
+81 -63
View File
@@ -1,83 +1,101 @@
package models
import (
"time"
"database/sql/driver"
"encoding/json"
"fmt"
)
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
// User represents an authenticated user
type User struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt UTCTime `json:"created_at"`
UpdatedAt UTCTime `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires time.Time `json:"expires" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline UTCTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires UTCTime `json:"expires" gorm:"not null"`
CreatedAt UTCTime `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time time.Time `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time UTCTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
Swap int64 `json:"swap" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
Disk int64 `json:"disk" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
NetIn int64 `json:"net_in" gorm:"type:bigint"`
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
// StringArray represents a slice of strings stored as JSON in the database
// StringArray 存储为 JSON 的字符串切片类型
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
}
return json.Unmarshal(bytes, sa)
}
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
+13 -11
View File
@@ -2,19 +2,21 @@ package models
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE"`
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 分钟
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
}
+17
View File
@@ -0,0 +1,17 @@
package models
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
TaskId uint `json:"task_id" gorm:"not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:TaskId;references:Id"`
Time UTCTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"`
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
}
+6 -28
View File
@@ -1,37 +1,15 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// StringArray represents a slice of strings stored as JSON in the database
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
}
return json.Unmarshal(bytes, sa)
}
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
}
type TaskResult struct {
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
Client string `json:"client" gorm:"type:varchar(36)"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
Client string `json:"client" gorm:"type:varchar(36)"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *UTCTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt UTCTime `json:"created_at" gorm:"type:timestamp"`
}
+230 -163
View File
@@ -2,6 +2,8 @@ package records
import (
"log"
"sort"
"strings"
"time"
"gorm.io/gorm"
@@ -18,7 +20,10 @@ func RecordOne(rec models.Record) error {
func DeleteAll() error {
db := dbcore.GetDBInstance()
return db.Exec("DELETE FROM Record").Error
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
@@ -29,98 +34,105 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
// 计算区间 [0.02, 0.98] 的平均值
func QuantileMean(values []float32) float32 {
if len(values) == 0 {
return 0
}
// 排序数据
sorted := make([]float32, len(values))
copy(sorted, values)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i] > sorted[j] {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
// 区间边缘index
lower := int(0.02 * float64(len(sorted)))
upper := int(0.98 * float64(len(sorted)))
if lower >= upper || lower >= len(sorted) {
return 0
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
sum := float32(0)
count := 0
for i := lower; i < upper && i < len(sorted); i++ {
sum += sorted[i]
count++
if len(long_term) == 0 {
// 没有查到long_term,返回全部recentRecords
records = append(records, recentRecords...)
return records, nil
}
if count == 0 {
return 0
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[key] = rec
}
}
return sum / float32(count)
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
})
records = append(records, groupedList...)
records = append(records, long_term...)
return records, nil
}
// 压缩数据库,针对每个ClientUUID,10分钟内的数据不动,24小时内的数据精简为每15分钟一条,3天为每30分钟一条,7天为每1小时一条,30天为每12小时一条。所有精简的数据是取 [0.02,0.98] 区间内的平均值
func GetAllRecords() ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
var long_term []models.Record
err := db.Table("records").Order("time ASC").Find(&records).Error
if err != nil {
log.Printf("Error fetching all records: %v", err)
return nil, err
}
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records: %v", err)
return records, nil
}
records = append(records, long_term...)
return records, nil
}
// 压缩数据库
func CompactRecord() error {
db := dbcore.GetDBInstance()
//log.Printf("Compacting records...")
var clientUUIDs []string
if err := db.Model(&models.Record{}).Distinct("client").Pluck("client", &clientUUIDs).Error; err != nil {
err := migrateOldRecords(db)
if err != nil {
log.Printf("Error migrating old records: %v", err)
return err
}
now := time.Now()
tenMinutesAgo := now.Add(-10 * time.Minute)
oneDayAgo := now.Add(-24 * time.Hour)
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
thirtyDaysAgo := now.Add(-30 * 24 * time.Hour)
for _, clientUUID := range clientUUIDs {
// Process each time range with specific aggregation intervals
if err := processTimeRange(db, clientUUID, thirtyDaysAgo, sevenDaysAgo, 12*time.Hour, now); err != nil {
log.Printf("Error compacting 30d-7d for client %s: %v", clientUUID, err)
continue
}
if err := processTimeRange(db, clientUUID, sevenDaysAgo, threeDaysAgo, 1*time.Hour, now); err != nil {
log.Printf("Error compacting 7d-3d for client %s: %v", clientUUID, err)
continue
}
if err := processTimeRange(db, clientUUID, threeDaysAgo, oneDayAgo, 30*time.Minute, now); err != nil {
log.Printf("Error compacting 3d-1d for client %s: %v", clientUUID, err)
continue
}
if err := processTimeRange(db, clientUUID, oneDayAgo, tenMinutesAgo, 15*time.Minute, now); err != nil {
log.Printf("Error compacting 1d-10m for client %s: %v", clientUUID, err)
continue
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
}
if flags.DatabaseType != "sqlite" {
return nil // Only vacuum for SQLite
}
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming the database: %v", err)
}
log.Printf("Record compaction completed")
//log.Printf("Record compaction completed")
return nil
}
func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, interval time.Duration, now time.Time) error {
// Round start time to the nearest interval
start = start.Truncate(interval)
end = end.Truncate(interval)
func migrateOldRecords(db *gorm.DB) error {
// 计算 4 小时前的时间
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// Get all records in the time range
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).
Order("time ASC").
Find(&records).Error; err != nil {
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
return err
}
@@ -128,109 +140,164 @@ func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, inte
return nil
}
// Group records by interval
type aggregatedRecord struct {
StartTime time.Time
Records []models.Record
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
Process []int
Connections []int
ConnectionsUdp []int
}
var aggregatedRecords []aggregatedRecord
currentGroup := aggregatedRecord{StartTime: start}
currentGroup.Records = make([]models.Record, 0)
groupedRecords := make(map[string]*groupData)
for _, record := range records {
groupTime := record.Time.Truncate(interval)
if !groupTime.Equal(currentGroup.StartTime) {
if len(currentGroup.Records) > 0 {
aggregatedRecords = append(aggregatedRecords, currentGroup)
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
data := groupedRecords[key]
data.Cpu = append(data.Cpu, record.Cpu)
data.Gpu = append(data.Gpu, record.Gpu)
data.Load = append(data.Load, record.Load)
data.Temp = append(data.Temp, record.Temp)
data.Ram = append(data.Ram, record.Ram)
data.RamTotal = append(data.RamTotal, record.RamTotal)
data.Swap = append(data.Swap, record.Swap)
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
data.Disk = append(data.Disk, record.Disk)
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
data.NetIn = append(data.NetIn, record.NetIn)
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
}
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
getInt32Percentile := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int(getPercentile(floats, percentile))
}
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedRecords {
// 解析 Client 和时间
parts := strings.Split(key, "_")
clientUUID := parts[0]
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
if err != nil {
return err
}
currentGroup = aggregatedRecord{StartTime: groupTime}
currentGroup.Records = []models.Record{record}
} else {
currentGroup.Records = append(currentGroup.Records, record)
}
}
if len(currentGroup.Records) > 0 {
aggregatedRecords = append(aggregatedRecords, currentGroup)
}
// Begin transaction
tx := db.Begin()
if tx.Error != nil {
return tx.Error
}
cpuFloats := make([]float64, len(data.Cpu))
for i, v := range data.Cpu {
cpuFloats[i] = float64(v)
}
gpuFloats := make([]float64, len(data.Gpu))
for i, v := range data.Gpu {
gpuFloats[i] = float64(v)
}
loadFloats := make([]float64, len(data.Load))
for i, v := range data.Load {
loadFloats[i] = float64(v)
}
tempFloats := make([]float64, len(data.Temp))
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
// 取高位
high_percentile := 0.7
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
return err
}
// Delete original records in the time range
if err := tx.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).Delete(&models.Record{}).Error; err != nil {
tx.Rollback()
return err
}
newRec := models.Record{
Client: clientUUID,
Time: models.FromTime(timeSlot),
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
Temp: float32(getPercentile(tempFloats, high_percentile)),
Ram: getIntPercentile(data.Ram, high_percentile),
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
Swap: getIntPercentile(data.Swap, high_percentile),
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, 0.2),
NetOut: getIntPercentile(data.NetOut, 0.2),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
}
// Insert aggregated records
for _, agg := range aggregatedRecords {
if len(agg.Records) == 0 {
continue
// 如果记录已存在则更新,否则创建新记录
if existingCount > 0 {
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
return err
}
} else {
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
return err
}
}
}
// Calculate averages and sums
var sumCPU, sumGPU, sumLOAD, sumTEMP float32
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal int64
var sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
var sumPROCESS, sumConnections, sumConnectionsUDP int
count := len(agg.Records)
for _, r := range agg.Records {
sumCPU += r.Cpu
sumGPU += r.Gpu
sumLOAD += r.Load
sumTEMP += r.Temp
sumRAM += r.Ram
sumRAMTotal += r.RamTotal
sumSWAP += r.Swap
sumSWAPTotal += r.SwapTotal
sumDISK += r.Disk
sumDISKTotal += r.DiskTotal
sumNETIn += r.NetIn
sumNETOut += r.NetOut
sumNETTotalUp += r.NetTotalUp
sumNETTotalDown += r.NetTotalDown
sumPROCESS += r.Process
sumConnections += r.Connections
sumConnectionsUDP += r.ConnectionsUdp
}
// Create new aggregated record
newRecord := models.Record{
Client: clientUUID,
Time: agg.StartTime,
Cpu: sumCPU / float32(count),
Gpu: sumGPU / float32(count),
Ram: sumRAM / int64(count),
RamTotal: sumRAMTotal / int64(count),
Swap: sumSWAP / int64(count),
SwapTotal: sumSWAPTotal / int64(count),
Load: sumLOAD / float32(count),
Temp: sumTEMP / float32(count),
Disk: sumDISK / int64(count),
DiskTotal: sumDISKTotal / int64(count),
NetIn: sumNETIn / int64(count),
NetOut: sumNETOut / int64(count),
NetTotalUp: sumNETTotalUp / int64(count),
NetTotalDown: sumNETTotalDown / int64(count),
Process: sumPROCESS / count,
Connections: sumConnections / count,
ConnectionsUdp: sumConnectionsUDP / count,
}
if err := tx.Create(&newRecord).Error; err != nil {
tx.Rollback()
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
}
return tx.Commit().Error
}
func Save(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
return nil
})
}
+130
View File
@@ -0,0 +1,130 @@
package records
import (
"encoding/csv"
"os"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/komari-monitor/komari/database/models"
)
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
var _ = func() bool {
// 确保 Test 环境中使用 sqlite 内存数据库
return true
}()
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
now := time.Now()
threshold := now.Add(-4 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: models.FromTime(recTime), Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
err := db.Create(&rec).Error
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(time.Hour)
expectedGroups[slot] = struct{}{}
} else {
expectedRemain++
}
}
// 导出原始数据到 CSV
os.MkdirAll("../../data", 0755)
var origRecs []models.Record
db.Order("time desc").Find(&origRecs)
fOrig, err := os.Create("../../data/original.csv")
assert.NoError(t, err)
defer fOrig.Close()
wOrig := csv.NewWriter(fOrig)
defer wOrig.Flush()
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
// 运行压缩(迁移)逻辑
err = migrateOldRecords(db)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
var longCount int64
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
assert.Equal(t, int64(len(expectedGroups)), longCount)
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRemain), remainCount+1)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
db.Table("records_long_term").Order("time desc").Find(&compRecs)
fComp, err := os.Create("../../data/compressed.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp := csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
db.Table("records").Order("time desc").Find(&compRecs)
fComp, err = os.Create("../../data/compressed_records.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp = csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
}
+111
View File
@@ -0,0 +1,111 @@
package tasks
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
)
func AddPingTask(clients []string, name string, target, task_type string, interval int) (uint, error) {
db := dbcore.GetDBInstance()
task := models.PingTask{
Clients: clients,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
}
if err := db.Create(&task).Error; err != nil {
return 0, err
}
ReloadPingSchedule()
return task.Id, nil
}
func DeletePingTask(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
ReloadPingSchedule()
return result.Error
}
func EditPingTask(tasks []*models.PingTask) error {
db := dbcore.GetDBInstance()
for _, task := range tasks {
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(task)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
ReloadPingSchedule()
return nil
}
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func SavePingRecord(record models.PingRecord) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
return err
}
func DeletePingRecords(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func DeleteAllPingRecords() error {
db := dbcore.GetDBInstance()
result := db.Exec("DELETE FROM ping_records")
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
if err := db.Find(&pingTasks).Error; err != nil {
return err
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
+1 -1
View File
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
Result: "",
ExitCode: nil,
FinishedAt: nil,
CreatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
})
}
if len(taskResults) > 0 {
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
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.10.0
+2
View File
@@ -74,6 +74,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
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=
+13 -3
View File
@@ -4,6 +4,7 @@ import (
"embed"
"errors"
"fmt"
"html"
"io"
"io/fs"
"log"
@@ -47,13 +48,22 @@ func initIndex() {
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
} else {
titleReplacement = fmt.Sprintf("<title>%s - Komari Monitor</title>", html.EscapeString(cfg.Sitename))
}
replaceMap := map[string]string{
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
"<title>Komari Monitor</title>": titleReplacement,
"A simple server monitor tool.": cfg.Description,
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, 1)
updated = strings.Replace(updated, k, v, -1)
}
IndexFile = updated
}
+3 -3
View File
@@ -22,11 +22,11 @@
- [x] Github单点登录、绑定、解绑
- [ ] 2FA
- [x] 2FA
- [ ] 负载通知
- [ ] 离线通知
- [x] 离线通知
- [ ] DDNS
@@ -36,4 +36,4 @@
- [x] 自动更新
- [ ] 慢速模式
- [x] 慢速模式(调整interval)
+21
View File
@@ -0,0 +1,21 @@
package geoip
import (
"fmt"
"net"
)
type EmptyProvider struct{}
func (e *EmptyProvider) Initialize() error {
return nil
}
func (e *EmptyProvider) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
return nil, fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) UpdateDatabase() error {
return fmt.Errorf("you are using an empty GeoIP provider, please set a valid provider")
}
func (e *EmptyProvider) Close() error {
return nil
}
+75 -81
View File
@@ -1,100 +1,33 @@
package geoip
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"unicode"
"github.com/oschwald/maxminddb-golang"
"github.com/komari-monitor/komari/database/config"
)
var (
GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
geoIpDb *maxminddb.Reader = nil
lock = &sync.RWMutex{}
)
var CurrentProvider GeoIPService
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
type GeoInfo struct {
ISOCode string
Name string
}
// 更新Geoip数据库,使用 GeoIpUrl下载最新的数据库文件,并覆盖本地的 GeoIpFilePath 文件
func UpdateGeoIpDatabase() error {
lock.Lock()
defer lock.Unlock()
if geoIpDb != nil {
geoIpDb.Close()
geoIpDb = nil
}
log.Println("Downloading GeoIP database...")
resp, err := http.Get(GeoIpUrl)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download file: %s", resp.Status)
}
out, err := os.Create(GeoIpFilePath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return err
}
return nil
func init() {
CurrentProvider = &EmptyProvider{}
}
func InitGeoIp() {
if os.MkdirAll("./data", os.ModePerm) != nil {
return
}
if _, err := os.Stat(GeoIpFilePath); os.IsNotExist(err) {
err := UpdateGeoIpDatabase()
if err != nil {
log.Println("Error updating GeoIP database:", err)
} else {
log.Println("GeoIP database updated successfully.")
}
}
var err error
geoIpDb, err = maxminddb.Open(GeoIpFilePath)
if err != nil {
log.Printf("Error opening GeoIP database: %v", err)
}
}
// GeoIPService 接口定义了获取地理位置信息的核心方法。
// 任何实现此接口的类型都可以作为地理位置服务提供者。
type GeoIPService interface {
GetGeoInfo(ip net.IP) (*GeoInfo, error)
func GetGeoIpInfo(ip net.IP) (*GeoIpRecord, error) {
lock.RLock()
defer lock.RUnlock()
if geoIpDb == nil {
InitGeoIp()
}
if ip == nil {
return nil, fmt.Errorf("IP address is nil")
}
var record GeoIpRecord
err := geoIpDb.Lookup(ip, &record)
if err != nil {
log.Printf("Error looking up IP %s: %v", ip.String(), err)
return nil, err
}
return &record, nil
UpdateDatabase() error
Close() error
}
func GetRegionUnicodeEmoji(isoCode string) string {
@@ -111,3 +44,64 @@ func GetRegionUnicodeEmoji(isoCode string) string {
rune2 := rune(0x1F1E6 + (rune(isoCode[1]) - 'A'))
return string(rune1) + string(rune2)
}
func InitGeoIp() {
conf, err := config.Get()
if err != nil {
panic("Failed to get configuration for GeoIP: " + err.Error())
}
if !conf.GeoIpEnabled {
return
}
switch conf.GeoIpProvider {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
}
case "ip-api":
NewCurrentProvider, err := NewIPAPIService()
if err != nil {
log.Printf("Failed to initialize ip-api service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ip-api.com as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
}
case "geojs":
NewCurrentProvider, err := NewGeoJSService()
if err != nil {
log.Printf("Failed to initialize GeoJS service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using geojs.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
}
case "ipinfo":
NewCurrentProvider, err := NewIPInfoService()
if err != nil {
log.Printf("Failed to initialize IPInfo service: " + err.Error())
}
if NewCurrentProvider != nil {
CurrentProvider = NewCurrentProvider
log.Println("Using ipinfo.io as GeoIP provider.")
} else {
CurrentProvider = &EmptyProvider{}
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
}
default:
CurrentProvider = &EmptyProvider{}
}
}
+23 -17
View File
@@ -2,58 +2,64 @@ package geoip_test
import (
"net"
"os"
"testing"
"github.com/komari-monitor/komari/utils/geoip"
)
// 测试GeoIP数据库的初始化和更新功能
func TestInitGeoIp(t *testing.T) {
geoip.InitGeoIp()
func TestMmdb(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewMaxMindGeoIPService()
testIpAddr(t)
}
func TestIpApi(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPAPIService()
testIpAddr(t)
}
// 检查数据库
fileInfo, err := os.Stat(geoip.GeoIpFilePath)
if err != nil {
t.Errorf("Failed to get file info: %v", err)
}
if fileInfo.Size() == 0 {
t.Errorf("GeoIP database file is empty: %s", geoip.GeoIpFilePath)
}
func TestGeojs(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewGeoJSService()
testIpAddr(t)
}
func TestIpInfo(t *testing.T) {
geoip.CurrentProvider, _ = geoip.NewIPInfoService()
testIpAddr(t)
}
func testIpAddr(t *testing.T) {
// IPv4
ipaddr := "8.8.8.8"
ip := net.ParseIP(ipaddr)
record, err := geoip.GetGeoIpInfo(ip)
record, err := geoip.CurrentProvider.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IP %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IP %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IP %s", ipaddr)
}
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv4:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
// IPv6
ipaddr = "2001:4860:4860::8888"
ip = net.ParseIP(ipaddr)
record, err = geoip.GetGeoIpInfo(ip)
record, err = geoip.CurrentProvider.GetGeoInfo(ip)
if err != nil {
t.Errorf("Failed to get GeoIP info for IPv6 %s: %v", ipaddr, err)
}
if record != nil {
if record.Country.ISOCode == "" && record.Country.Names["zh-CN"] == "" {
if record.ISOCode == "" && record.Name == "" {
t.Errorf("Country information is missing for IPv6 %s", ipaddr)
}
} else {
t.Errorf("GeoIP record is nil for IPv6 %s", ipaddr)
}
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.Country.ISOCode, record.Country.Names["zh-CN"])
t.Logf("IPv6:[%s]%s - %s", ipaddr, record.ISOCode, record.Name)
}
func TestUnicodeEmoji(t *testing.T) {
+75
View File
@@ -0,0 +1,75 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// GeoJSService 使用 geojs.io 服务实现 GeoIPService 接口。
type GeoJSService struct {
Client *http.Client
}
// geoJSResponse 定义了 geojs.io 服务返回的 JSON 响应的结构。
// 我们只定义我们需要的字段。
type geoJSResponse struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
// 可以根据需要添加其他字段,例如:
// City string `json:"city"`
// Region string `json:"region"`
}
// NewGeoJSService 创建并返回一个 GeoJSService 的新实例。
func NewGeoJSService() (*GeoJSService, error) {
return &GeoJSService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置一个合理的超时时间
},
}, nil
}
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// GeoJS 的 API 端点
apiURL := fmt.Sprintf("https://get.geojs.io/v1/ip/geo/%s.json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from geojs.io: %w", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("geojs.io returned non-200 status code: %d", resp.StatusCode)
}
var apiResp geoJSResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode geojs.io response: %w", err)
}
// 检查国家代码是否为空,因为 geojs 对无效/私有IP可能返回200 OK但内容为空
if apiResp.CountryCode == "" {
return nil, fmt.Errorf("geojs.io returned empty geo info for ip: %s", ip.String())
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 geojs.io 是一个空操作,因为它是一个 Web 服务。
func (s *GeoJSService) UpdateDatabase() error {
return nil
}
// Close 对于 geojs.io 是一个空操作。
func (s *GeoJSService) Close() error {
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPAPIService 使用 ip-api.com 服务实现 GeoIPService 接口。
type IPAPIService struct {
Client *http.Client
}
// ipAPIResponse 定义了 ip-api.com 服务返回的 JSON 响应的结构。
type ipAPIResponse struct {
Status string `json:"status"`
Message string `json:"message"` // 当 status 为 fail 时出现
Country string `json:"country"`
CountryCode string `json:"countryCode"`
Region string `json:"region"`
RegionName string `json:"regionName"`
City string `json:"city"`
Zip string `json:"zip"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
ISP string `json:"isp"`
Org string `json:"org"`
As string `json:"as"`
Query string `json:"query"`
}
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
func NewIPAPIService() (*IPAPIService, error) {
return &IPAPIService{
Client: &http.Client{
Timeout: 5 * time.Second, // 设置请求超时
},
}, nil
}
// GetGeoInfo 使用 ip-api.com 服务检索给定 IP 地址的地理位置信息。
func (s *IPAPIService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// API URL, 使用 fields 参数来仅请求需要的字段
apiURL := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ip-api.com: %w", err)
}
defer resp.Body.Close()
var apiResp ipAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ip-api.com response: %w", err)
}
if apiResp.Status != "success" {
return nil, fmt.Errorf("ip-api.com returned an error: %s", apiResp.Message)
}
return &GeoInfo{
ISOCode: apiResp.CountryCode,
Name: apiResp.Country,
}, nil
}
// UpdateDatabase 对于 ip-api.com 是一个空操作,因为它是一个 Web 服务。
func (s *IPAPIService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ip-api.com 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPAPIService) Close() error {
// 无需执行任何操作
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package geoip
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
// IPInfoService 使用 ipinfo.io 服务实现 GeoIPService 接口。
type IPInfoService struct {
Client *http.Client
// 每天 1000 次请求,限制由 IP 地址的所有人共享。
// APIToken string
}
// ipInfoResponse 定义了 ipinfo.io 服务返回的 JSON 响应的结构,只包含免费额度可用的字段。
type ipInfoResponse struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryCode string `json:"countryCode"` // ipinfo.io 返回 "country" 的 ISO 代码,这里为了与 GeoInfo 保持一致,额外添加一个 CountryCode
Loc string `json:"loc"` // Latitude,Longitude
Org string `json:"org"`
Postal string `json:"postal"`
Timezone string `json:"timezone"`
}
// NewIPInfoService 创建并返回一个 IPInfoService 的新实例。
func NewIPInfoService() (*IPInfoService, error) {
return &IPInfoService{
Client: &http.Client{
Timeout: 5 * time.Second,
},
}, nil
}
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
// 免费额度主要提供国家信息。
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
// IPinfo 免费额度不需要 API token 就可以查询基本的 IP 信息。
// API URL: https://ipinfo.io/json (查询自身IP) 或 https://ipinfo.io/YOUR_IP/json
apiURL := fmt.Sprintf("https://ipinfo.io/%s/json", ip.String())
resp, err := s.Client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to get geo info from ipinfo.io: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ipinfo.io returned non-200 status: %d %s", resp.StatusCode, resp.Status)
}
var apiResp ipInfoResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to decode ipinfo.io response: %w", err)
}
// IPinfo 的 "country" 字段直接返回 ISO 2-letter code,例如 "US", "CN"
// 我们需要将 "country" 字段作为 ISOCode,并尝试获取其对应的国家名称。
// IPinfo 响应中通常不直接提供完整的国家名称,但我们可以通过 CountryCode 映射。
// 为了简化并符合 GeoInfo 结构,我们直接使用 Country 作为 ISOCode,并尝试从 CountryCode 获取名称。
// 实际上,IPinfo 的 'country' 字段就是 ISO 2-letter code。
// 如果需要完整的国家名称,可能需要一个本地的 ISO 代码到名称的映射。
// 为了与 GetRegionUnicodeEmoji 函数兼容,我们直接使用 country 作为 ISOCode。
return &GeoInfo{
ISOCode: apiResp.Country, // IPinfo 的 'country' 字段就是 ISO 2-letter code
Name: apiResp.Country, // 免费额度通常只提供 ISO 编码,这里暂时用 ISO 编码作为名称
}, nil
}
// UpdateDatabase 对于 ipinfo.io 是一个空操作,因为它是一个 Web 服务。
func (s *IPInfoService) UpdateDatabase() error {
// 无需执行任何操作,因为数据由外部服务提供
return nil
}
// Close 对于 ipinfo.io 是一个空操作,因为没有需要关闭的持久连接。
func (s *IPInfoService) Close() error {
// 无需执行任何操作
return nil
}
+176
View File
@@ -0,0 +1,176 @@
package geoip // 与 geoip.go 保持相同的包名,表示它们是同一个包的组成部分
import (
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath" // 新增导入,用于处理文件路径
"sync"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/oschwald/maxminddb-golang"
)
// GeoIpUrl 是 MaxMind 数据库的下载地址。
var GeoIpUrl = "https://raw.githubusercontent.com/Loyalsoldier/geoip/release/GeoLite2-Country.mmdb"
// GeoIpFilePath 是本地存储 MaxMind 数据库的路径。
var GeoIpFilePath = "./data/GeoLite2-Country.mmdb"
// GeoIpRecord 结构体定义了 MaxMind 数据库查询结果的原始结构。
// 它是 MaxMind 库特有的,用于从 .mmdb 文件中解析数据。
type GeoIpRecord struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
// MaxMindGeoIPService 是 GeoIPService 接口的一个具体实现,
// 它使用 MaxMind 数据库作为后端。
type MaxMindGeoIPService struct {
// maxMindDBReader 内部持有的 MaxMind 数据库读取器实例。
maxMindDBReader *maxminddb.Reader
// dbFilePath 是 MaxMind 数据库文件的路径。
dbFilePath string
// mu 用于保护对 maxMindDBReader 的并发访问,确保线程安全。
mu sync.RWMutex
}
// NewMaxMindGeoIPService 创建并返回一个 MaxMindGeoIPService 实例。
// 它负责初始化服务,包括尝试加载或下载数据库。
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
dbFilePath := GeoIpFilePath
service := &MaxMindGeoIPService{
dbFilePath: dbFilePath,
}
// 确保数据目录存在
if err := os.MkdirAll(filepath.Dir(dbFilePath), os.ModePerm); err != nil {
logOperation.Log("", "", "Failed to create data directory for MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to create data directory for MaxMind database: %w", err)
}
// 检查数据库文件是否存在,如果不存在则尝试下载
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
if err := service.UpdateDatabase(); err != nil {
logOperation.Log("", "", "Failed to download initial MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err)
}
}
// 初始化或重新加载 MaxMind 数据库。
if err := service.initialize(); err != nil {
logOperation.Log("", "", "Failed to initialize MaxMind database: "+err.Error(), "error")
return nil, fmt.Errorf("failed to initialize MaxMind database: %w", err)
}
return service, nil
}
// initialize 初始化或重新加载 MaxMind 数据库。
// 这是一个内部方法,供 NewMaxMindGeoIPService 和 UpdateDatabase 调用。
// 它会关闭现有连接(如果存在)并重新打开数据库文件。
func (s *MaxMindGeoIPService) initialize() error {
s.mu.Lock()
defer s.mu.Unlock()
// 如果已存在数据库读取器,则先关闭它。
if s.maxMindDBReader != nil {
s.maxMindDBReader.Close()
s.maxMindDBReader = nil
}
// 尝试打开新的数据库文件。
reader, err := maxminddb.Open(s.dbFilePath)
if err != nil {
return fmt.Errorf("error opening MaxMind database at %s: %w", s.dbFilePath, err)
}
s.maxMindDBReader = reader
return nil
}
// GetGeoInfo 根据 IP 地址获取 MaxMind 的地理位置信息。
// 它查询 MaxMind 数据库并将其特有的 GeoIpRecord 转换为通用的 GeoInfo 结构体。
func (s *MaxMindGeoIPService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.maxMindDBReader == nil {
return nil, fmt.Errorf("MaxMind database is not initialized or failed to open")
}
if ip == nil {
return nil, fmt.Errorf("IP address cannot be nil")
}
var record GeoIpRecord // 使用原始的 GeoIpRecord 结构体来接收查询结果
err := s.maxMindDBReader.Lookup(ip, &record)
if err != nil {
// 返回错误,但避免直接返回 maxminddb 库的内部错误,提供更友好的信息
return nil, fmt.Errorf("error looking up IP %s in MaxMind database: %w", ip.String(), err)
}
// 将 MaxMind 的特定结构体转换为通用的 GeoInfo 结构体
geoInfo := &GeoInfo{
ISOCode: record.Country.ISOCode,
// 尝试获取英文国家名称,如果不存在则使用 ISO 代码作为备用
Name: record.Country.Names["en"],
}
if geoInfo.Name == "" && geoInfo.ISOCode != "" {
geoInfo.Name = geoInfo.ISOCode // 如果没有英文名称,回退到 ISO 代码
}
return geoInfo, nil
}
// UpdateDatabase 实现了 GeoIPService 接口的 UpdateDatabase 方法。
// 它会下载最新的 GeoLite2-Country.mmdb 文件并重新加载数据库。
func (s *MaxMindGeoIPService) UpdateDatabase() error {
s.mu.Lock() // 获取写锁,确保更新过程的互斥性
resp, err := http.Get(GeoIpUrl) // GeoIpUrl 是预定义的 MaxMind 数据库下载地址
if err != nil {
return fmt.Errorf("failed to initiate MaxMind database download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download MaxMind database: HTTP status %s", resp.Status)
}
// 确保数据目录存在(NewMaxMindGeoIPService 已处理,但这里再次确保以防直接调用)
if err := os.MkdirAll(filepath.Dir(s.dbFilePath), os.ModePerm); err != nil {
return fmt.Errorf("failed to create data directory for MaxMind database update: %w", err)
}
out, err := os.Create(s.dbFilePath) // 创建或覆盖本地数据库文件
if err != nil {
return fmt.Errorf("failed to create MaxMind database file at %s: %w", s.dbFilePath, err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body) // 将下载内容写入文件
if err != nil {
return fmt.Errorf("failed to write MaxMind database file: %w", err)
}
s.mu.Unlock() // initialize 方法需要在解锁后调用,以避免死锁
// 重新加载数据库以使用新下载的文件
return s.initialize()
}
// Close 实现了 GeoIPService 接口的 Close 方法。
// 它关闭 MaxMind 数据库读取器,释放文件句柄和其他资源。
func (s *MaxMindGeoIPService) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.maxMindDBReader != nil {
err := s.maxMindDBReader.Close()
s.maxMindDBReader = nil // 清空读取器实例
if err != nil {
return fmt.Errorf("error closing MaxMind database: %w", err)
}
}
log.Println("MaxMind GeoIP service closed.")
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package messageSender
import (
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"github.com/komari-monitor/komari/database/config"
)
// EmailMessageSender implements the MessageSender interface for Email.
type EmailMessageSender struct{}
// SendTextMessage sends a text message via Email (SMTP).
// The title is used as the email subject.
func (e *EmailMessageSender) SendTextMessage(message, title string) error {
conf, err := config.Get()
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
if conf.EmailHost == "" || conf.EmailSender == "" || conf.EmailUsername == "" || conf.EmailPassword == "" || conf.EmailReceiver == "" {
return fmt.Errorf("email sending is not fully configured")
}
auth := smtp.PlainAuth(
"",
conf.EmailUsername,
conf.EmailPassword,
conf.EmailHost,
)
msg := []byte("To: " + conf.EmailReceiver + "\r\n" +
"From: " + conf.EmailSender + "\r\n" +
"Subject: " + title + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
message)
addr := conf.EmailHost + ":" + strconv.Itoa(conf.EmailPort)
if conf.EmailUseSSL {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.StartTLS(&tls.Config{ServerName: conf.EmailHost}); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
// Authenticate
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
// Set the sender and recipient
if err = c.Mail(conf.EmailSender); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err = c.Rcpt(conf.EmailReceiver); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return c.Quit()
} else {
// Send without SSL/TLS (less secure)
return smtp.SendMail(
addr,
auth,
conf.EmailSender,
[]string{conf.EmailReceiver},
msg,
)
}
}
+9
View File
@@ -0,0 +1,9 @@
package messageSender
import "fmt"
type EmptyProvider struct{}
func (e *EmptyProvider) SendTextMessage(message, title string) error {
return fmt.Errorf("you are using an empty message sender, please check your configuration")
}
+38
View File
@@ -0,0 +1,38 @@
package messageSender
import (
"github.com/komari-monitor/komari/database/config"
)
var CurrentProvider MessageSender
func init() {
CurrentProvider = &EmptyProvider{}
}
func Initialize() {
cfg, err := config.Get()
if err != nil {
CurrentProvider = &EmptyProvider{}
return
}
switch cfg.NotificationMethod {
case "telegram":
CurrentProvider = &TelegramMessageSender{}
case "email":
CurrentProvider = &EmailMessageSender{}
case "none", "":
CurrentProvider = &EmptyProvider{}
default:
CurrentProvider = &EmptyProvider{}
}
}
type MessageSender interface {
SendTextMessage(message, title string) error
}
func SendTextMessage(message string, title string) error {
return CurrentProvider.SendTextMessage(message, title)
}
@@ -1,4 +1,4 @@
package telegram
package messageSender
import (
"encoding/json"
@@ -10,27 +10,36 @@ import (
"github.com/komari-monitor/komari/database/config"
)
func SendMessage(message, msgType string) error {
// TelegramMessageSender implements the MessageSender interface for Telegram.
type TelegramMessageSender struct{}
// SendTextMessage sends a text message via Telegram API.
// The title is prepended to the message.
func (t *TelegramMessageSender) SendTextMessage(message, title string) error {
fullMessage := message
if title != "" {
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
}
var lastErr error
for i := 0; i < 3; i++ {
for i := 0; i < 3; i++ { // Retry mechanism
conf, err := config.Get()
if err != nil {
lastErr = err
continue
}
if !conf.TelegramEnabled || message == "" {
return errors.New("telegram is disabled or message is empty")
if fullMessage == "" {
return errors.New("telegram is disabled or message is empty") // Assuming disabled if message is empty
}
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", conf.TelegramChatID)
data.Set("text", message)
if msgType != "text" {
data.Set("parse_mode", msgType)
}
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML") // Use HTML for basic formatting if title is present
resp, err := http.PostForm(endpoint, data)
if err != nil {
lastErr = fmt.Errorf("failed to send message: %v", err)
@@ -60,17 +69,4 @@ func SendMessage(message, msgType string) error {
return nil
}
return lastErr
}
// SendTextMessage sends a text message via Telegram API
func SendTextMessage(message string) error {
return SendMessage(message, "text")
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func SendMarkdownMessage(message string) error {
return SendMessage(message, "MarkdownV2")
}
func SendHTMLMessage(message string) error {
return SendMessage(message, "HTML")
}
}
+81
View File
@@ -0,0 +1,81 @@
package notification
import (
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/messageSender"
)
func CheckExpireScheduledWork() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
if now.After(next) {
next = next.Add(24 * time.Hour)
}
duration := next.Sub(now)
time.Sleep(duration)
cfg, err := config.Get()
if err != nil {
time.Sleep(time.Second)
continue
}
if !cfg.ExpireNotificationEnabled {
time.Sleep(time.Second)
continue
}
notificationLeadDays := cfg.ExpireNotificationLeadDays
clients_all, err := clients.GetAllClientBasicInfo()
if err != nil {
time.Sleep(time.Second)
continue
}
type clientToExpireInfo struct {
Name string
DaysLeft int
}
var clientLeadToExpire []clientToExpireInfo
checkTime := time.Now()
for _, client := range clients_all {
clientExpireTime := client.ExpiredAt.ToTime()
if clientExpireTime.Before(checkTime) {
continue
}
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
remainingDuration := clientExpireTime.Sub(checkTime)
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
Name: client.Name,
DaysLeft: daysLeft,
})
}
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("%s in %d days.\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
}
// 等待1秒,防止多次触发
time.Sleep(time.Second)
}
}
+99 -90
View File
@@ -10,140 +10,149 @@ import (
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/telegram"
"github.com/komari-monitor/komari/utils/messageSender"
)
var (
lastNotified = make(map[string]time.Time)
lastOnlineNotified = make(map[string]time.Time)
pendingOffline = make(map[string]time.Time)
// notificationState 保存单个客户端的通知状态。
// 通过在结构体中嵌入互斥锁,实现每个客户端细粒度的锁定,比全局锁更高效。
type notificationState struct {
mu sync.Mutex // 互斥锁,保护该客户端状态
pendingOfflineSince time.Time // 客户端离线的时间。为零值表示客户端在线或已发送离线通知。
isFirstConnection bool // 标记是否为首次上线连接。
}
mu sync.Mutex
)
// clientStates 使用 sync.Map 实现对客户端状态的并发访问。
// 映射关系:clientID (string) -> *notificationState
var clientStates sync.Map
// OfflineNotification sends an offline notification for the client if enabled and not sent in cooldown period
// getNotificationConfig 获取指定客户端的通知配置。
// 返回配置对象和一个布尔值,指示全局和该客户端是否启用通知。
func getNotificationConfig(clientID string) (*models.OfflineNotification, bool) {
conf, err := config.Get()
if err != nil || !conf.NotificationEnabled {
return nil, false
}
notiConf := models.OfflineNotification{Client: clientID}
db := dbcore.GetDBInstance()
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&notiConf).Error; err != nil {
log.Printf("Failed to get or create offline notification config for client %s: %v", clientID, err)
return nil, false
}
return &notiConf, notiConf.Enable
}
// getOrInitState 从 sync.Map 获取客户端状态,不存在则新建并存储。
func getOrInitState(clientID string) *notificationState {
// 原子性地加载或存储该客户端的状态。
val, _ := clientStates.LoadOrStore(clientID, &notificationState{isFirstConnection: true})
return val.(*notificationState)
}
// OfflineNotification 在启用通知且未在宽限期内发送的情况下,发送客户端离线通知。
func OfflineNotification(clientID string) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
now := time.Now()
conf, _ := config.Get()
if !conf.NotificationEnabled {
notiConf, enabled := getNotificationConfig(clientID)
if !enabled {
return
}
noti_conf := models.OfflineNotification{
Client: clientID,
}
db := dbcore.GetDBInstance()
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&noti_conf).Error
if err != nil {
log.Println("Failed to get or create offline notification config:", err)
return
}
if !noti_conf.Enable {
return
}
cooldownDuration := time.Duration(noti_conf.Cooldown) * time.Second
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
if cooldownDuration <= 0 {
cooldownDuration = 30 * time.Minute // default cooldown if not set
}
gracePeriod := time.Duration(notiConf.GracePeriod) * time.Second
if gracePeriod <= 0 {
gracePeriod = 5 * time.Minute // default grace period if not set
gracePeriod = 5 * time.Minute // 默认宽限期
}
mu.Lock()
if _, exists := pendingOffline[clientID]; exists {
mu.Unlock()
now := time.Now()
state := getOrInitState(clientID)
state.mu.Lock()
// 如果已处于待通知状态,则不做处理。
if !state.pendingOfflineSince.IsZero() {
state.mu.Unlock()
return
}
pendingOffline[clientID] = now
mu.Unlock()
// 标记该客户端为待离线。
state.pendingOfflineSince = now
state.mu.Unlock()
go func(start time.Time) {
// 新建协程,等待宽限期后判断是否需要发送通知。
go func(startTime time.Time) {
time.Sleep(gracePeriod)
mu.Lock()
defer mu.Unlock()
// if no longer pending (client reconnected), skip
if ts, ok := pendingOffline[clientID]; !ok || !ts.Equal(start) {
return
}
// check cooldown
if last, ok := lastNotified[clientID]; ok && time.Since(last) < cooldownDuration {
delete(pendingOffline, clientID)
return
}
// record notification time and clear pending
lastNotified[clientID] = time.Now()
delete(pendingOffline, clientID)
message := fmt.Sprintf("🔴<code>%s</code> is offline", client.Name)
state.mu.Lock()
defer state.mu.Unlock()
// 检查离线状态是否仍为本次协程启动时的状态。
// 若为零值,说明客户端已重连。
// 若时间不同,说明客户端重连后又断开,已启动新计时。
if state.pendingOfflineSince.IsZero() || !state.pendingOfflineSince.Equal(startTime) {
return
}
// 即将发送通知,重置待通知状态。
state.pendingOfflineSince = time.Time{}
// Send notification
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := telegram.SendHTMLMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
// 更新数据库中的最后通知时间
db := dbcore.GetDBInstance()
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now).Error; err != nil {
log.Printf("Failed to update last_notified for client %s: %v", clientID, err)
}
}(now)
}
// OnlineNotification sends an online notification for the client if enabled and not sent in cooldown period
// OnlineNotification 在启用通知的情况下,发送客户端上线通知。
func OnlineNotification(clientID string) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
noti_conf := models.OfflineNotification{
Client: clientID,
}
db := dbcore.GetDBInstance()
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&noti_conf).Error
if err != nil {
log.Println("Failed to get or create offline notification config:", err)
_, enabled := getNotificationConfig(clientID)
if !enabled {
return
}
if !noti_conf.Enable {
state := getOrInitState(clientID)
state.mu.Lock()
defer state.mu.Unlock()
// 规则1:首次连接不通知。
if state.isFirstConnection {
state.isFirstConnection = false
// 同时清除任何待离线状态(如服务器重启时客户端本已离线)
state.pendingOfflineSince = time.Time{}
return
}
cooldownDuration := time.Duration(noti_conf.Cooldown) * time.Second
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
if cooldownDuration <= 0 {
cooldownDuration = 30 * time.Minute // default cooldown if not set
}
if gracePeriod <= 0 {
gracePeriod = 5 * time.Minute // default grace period if not set
}
// clear any pending offline debounce
mu.Lock()
delete(pendingOffline, clientID)
mu.Unlock()
now := time.Now()
// 检查客户端是否处于待离线状态。
wasPending := !state.pendingOfflineSince.IsZero()
// 上线时总是清除待离线状态。
state.pendingOfflineSince = time.Time{}
mu.Lock()
defer mu.Unlock()
// if never went offline before, skip first online notification
if _, wasOffline := lastNotified[clientID]; !wasOffline {
// 规则4:宽限期内重连,不通知。
if wasPending {
return
}
// cooldown for online notifications
if last, exists := lastOnlineNotified[clientID]; exists && now.Sub(last) < cooldownDuration {
return
}
// send online notification
message := fmt.Sprintf("🟢<code>%s</code> is online", client.Name)
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := telegram.SendHTMLMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
lastOnlineNotified[clientID] = now
delete(lastNotified, clientID)
}
+94
View File
@@ -0,0 +1,94 @@
package utils
import (
"sync"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
)
// PingTaskManager 管理定时器和任务
type PingTaskManager struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.PingTask
stopChan chan struct{}
}
var manager = &PingTaskManager{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.PingTask),
stopChan: make(chan struct{}),
}
// Reload 重载时间表
func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
}
m.tickers = make(map[int]*time.Ticker)
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
taskGroups := make(map[int][]models.PingTask)
for _, task := range pingTasks {
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
m.tickers[interval] = ticker
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.PingTask) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executePingTask(task)
}
case <-m.stopChan:
return
}
}
}(ticker, tasks)
}
return nil
}
// executePingTask 执行单个PingTask
func executePingTask(task models.PingTask) {
clients := ws.GetConnectedClients()
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
for _, clientUUID := range task.Clients {
if conn, exists := clients[clientUUID]; exists {
if conn == nil {
continue
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
if err := conn.WriteJSON(message); err != nil {
continue
}
}
}
}
// ReloadPingSchedule 加载或重载时间表
func ReloadPingSchedule(pingTasks []models.PingTask) error {
return manager.Reload(pingTasks)
}
+88
View File
@@ -0,0 +1,88 @@
package utils
import (
"sync"
)
// SafeMap 是一个线程安全的泛型映射类型
// 使用 sync.RWMutex 来确保并发访问的安全性
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex // 读写锁,用于保护 items 的并发访问
items map[K]V // 存储键值对的底层映射
}
// NewSafeMap 创建一个新的 SafeMap 实例
// 返回一个指向 SafeMap 的指针
func NewSafeMap[K comparable, V any]() *SafeMap[K, V] {
return &SafeMap[K, V]{
items: make(map[K]V), // 初始化底层映射
}
}
// Set 添加或更新映射中的键值对
// 使用写锁保护操作
func (sm *SafeMap[K, V]) Set(key K, value V) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.items[key] = value
}
// Store 添加或更新映射中的键值对
// 是 Set 方法的别名
func (sm *SafeMap[K, V]) Store(key K, value V) {
sm.Set(key, value)
}
// Get 根据键从映射中检索值
// 返回值和一个布尔值,指示键是否存在
func (sm *SafeMap[K, V]) Get(key K) (V, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
value, exists := sm.items[key]
return value, exists
}
// Delete 从映射中移除指定键的键值对
// 使用写锁保护操作
func (sm *SafeMap[K, V]) Delete(key K) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.items, key)
}
// Keys 返回映射中所有键的切片
// 使用读锁保护操作
func (sm *SafeMap[K, V]) Keys() []K {
sm.mu.RLock()
defer sm.mu.RUnlock()
keys := make([]K, 0, len(sm.items))
for key := range sm.items {
keys = append(keys, key)
}
return keys
}
// Len 返回映射中键值对的数量
// 使用读锁保护操作
func (sm *SafeMap[K, V]) Len() int {
sm.mu.RLock()
defer sm.mu.RUnlock()
return len(sm.items)
}
// Range 遍历映射中的所有键值对
// 接受一个函数作为参数,如果函数返回 false,则停止遍历
func (sm *SafeMap[K, V]) Range(f func(key K, value V) bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
for key, value := range sm.items {
if !f(key, value) {
break
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report) models.
// Create new aggregated record
newRecord := models.Record{
Client: uuid,
Time: time,
Time: models.FromTime(time),
Cpu: sumCPU / float32(count),
Gpu: sumGPU / float32(count),
Ram: sumRAM / int64(count),
+4 -4
View File
@@ -47,15 +47,15 @@ func GetClients(c *gin.Context) {
continue
}
// 已建立连接的客户端uuid列表
for key := range ConnectedClients {
for key := range GetConnectedClients() {
resp.Online = append(resp.Online, key)
}
// 清除UUID,简化报告单
for _, report := range LatestReport {
resp.Data = GetLatestReport()
for _, report := range resp.Data {
report.UUID = ""
}
resp.Data = LatestReport
for _, report := range LatestReport {
for _, report := range resp.Data {
if report.CPU.Usage == 0 {
report.CPU.Usage = 0.01
}
+48 -2
View File
@@ -1,12 +1,58 @@
package ws
import (
"sync"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
)
var (
ConnectedClients = make(map[string]*websocket.Conn)
connectedClients = make(map[string]*SafeConn)
ConnectedUsers = []*websocket.Conn{}
LatestReport = make(map[string]*common.Report)
latestReport = make(map[string]*common.Report)
mu = sync.RWMutex{}
)
func GetConnectedClients() map[string]*SafeConn {
mu.RLock()
defer mu.RUnlock()
clientsCopy := make(map[string]*SafeConn)
for k, v := range connectedClients {
clientsCopy[k] = v
}
return clientsCopy
}
func SetConnectedClients(uuid string, conn *SafeConn) {
mu.Lock()
defer mu.Unlock()
connectedClients[uuid] = conn
}
func DeleteConnectedClients(uuid string) {
mu.Lock()
defer mu.Unlock()
if conn, exists := connectedClients[uuid]; exists {
conn.Close()
}
delete(connectedClients, uuid)
}
func GetLatestReport() map[string]*common.Report {
mu.RLock()
defer mu.RUnlock()
reportCopy := make(map[string]*common.Report)
for k, v := range latestReport {
reportCopy[k] = v
}
return reportCopy
}
func SetLatestReport(uuid string, report *common.Report) {
mu.Lock()
defer mu.Unlock()
latestReport[uuid] = report
}
func DeleteLatestReport(uuid string) {
mu.Lock()
defer mu.Unlock()
delete(latestReport, uuid)
}
+52
View File
@@ -0,0 +1,52 @@
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
type SafeConn struct {
conn *websocket.Conn
mu sync.Mutex
}
func NewSafeConn(conn *websocket.Conn) *SafeConn {
return &SafeConn{
conn: conn,
mu: sync.Mutex{},
}
}
func (sc *SafeConn) WriteMessage(messageType int, data []byte) error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.WriteMessage(messageType, data)
}
func (sc *SafeConn) WriteJSON(v interface{}) error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.WriteJSON(v)
}
func (sc *SafeConn) Close() error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.Close()
}
func (sc *SafeConn) ReadMessage() (int, []byte, error) {
return sc.conn.ReadMessage()
}
func (sc *SafeConn) ReadJSON(v interface{}) error {
return sc.conn.ReadJSON(v)
}
func (sc *SafeConn) SetReadDeadline(t time.Time) error {
return sc.conn.SetReadDeadline(t)
}
func (sc *SafeConn) GetConn() *websocket.Conn {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn
}