Compare commits

..
Author SHA1 Message Date
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
55 changed files with 1847 additions and 520 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"]
+43 -16
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,30 +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
if uuid == "" {
continue
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered)
records = append(records, r)
}
}
r := utils.AverageReport(uuid, time.Now(), filtered)
record = append(record, 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 自动调用
}
+1
View File
@@ -0,0 +1 @@
package notification
+3 -3
View File
@@ -62,9 +62,9 @@ func EditPingTask(c *gin.Context) {
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})
}
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
}
}
+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)
}
+4 -4
View File
@@ -10,11 +10,11 @@ import (
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestTelegram(c *gin.Context) {
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.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
}
+7 -4
View File
@@ -19,6 +19,7 @@ import (
"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) {
@@ -166,7 +167,7 @@ func WebSocketReport(c *gin.Context) {
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: reqBody.FinishedAt,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
@@ -178,13 +179,15 @@ func WebSocketReport(c *gin.Context) {
}
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)
}
+25 -4
View File
@@ -69,17 +69,38 @@ func GetPingRecords(c *gin.Context) {
}
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 {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
})
// 只保留分配给该 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,
})
}
}
response["tasks"] = tasksList
}
+18 -2
View File
@@ -30,6 +30,8 @@ import (
"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"
)
@@ -53,6 +55,7 @@ var ServerCmd = &cobra.Command{
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
r := gin.Default()
@@ -64,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 {
@@ -117,11 +126,12 @@ var ServerCmd = &cobra.Command{
// #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")
@@ -162,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")
@@ -284,16 +295,21 @@ func DoScheduledWork() {
//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 * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
logOperation.RemoveOldLogs()
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
+1 -1
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)
+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"`
}
+16 -8
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)"`
@@ -23,13 +21,23 @@ type Config struct {
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"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
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:72"` // Ping 记录保留时间,单位小时,默认3天
CreatedAt time.Time
UpdatedAt time.Time
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"`
}
+62 -63
View File
@@ -4,85 +4,84 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
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
+10 -12
View File
@@ -1,24 +1,22 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified time.Time `json:"last_notified"` // 上次通知时间
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;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 time.Time `json:"last_notified"` // 上次通知时间
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"` // 上次通知时间
}
+4 -6
View File
@@ -1,12 +1,10 @@
package models
import "time"
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 time.Time `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
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 {
+6 -10
View File
@@ -1,19 +1,15 @@
package models
import (
"time"
)
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
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"`
}
+37 -35
View File
@@ -42,49 +42,50 @@ func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Reco
db := dbcore.GetDBInstance()
var records []models.Record
threeHoursAgo := time.Now().Add(-3 * time.Hour)
interval := end.Sub(start)
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
if end.After(threeHoursAgo) {
var recentRecords []models.Record
recentStart := start
if recentStart.Before(threeHoursAgo) {
recentStart = threeHoursAgo
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
}
if interval > 4*time.Hour {
// 按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.After(old.Time) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.Before(groupedList[j].Time)
})
records = append(records, groupedList...)
} else {
// 查询区间不超过,直接返回全部recentRecords
records = append(records, recentRecords...)
}
}
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
if len(long_term) == 0 {
// 没有查到long_term,返回全部recentRecords
records = append(records, recentRecords...)
return records, nil
}
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
})
records = append(records, groupedList...)
records = append(records, long_term...)
return records, nil
}
@@ -121,7 +122,7 @@ func CompactRecord() error {
log.Printf("Error vacuuming database: %v", err)
}
}
log.Printf("Record compaction completed")
//log.Printf("Record compaction completed")
return nil
}
@@ -162,7 +163,7 @@ func migrateOldRecords(db *gorm.DB) error {
groupedRecords := make(map[string]*groupData)
for _, record := range records {
key := record.Client + "_" + record.Time.Truncate(15*time.Minute).Format(time.RFC3339)
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
@@ -250,7 +251,8 @@ func migrateOldRecords(db *gorm.DB) error {
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
high_percentile := 0.8
// 取高位
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 {
@@ -259,7 +261,7 @@ func migrateOldRecords(db *gorm.DB) error {
newRec := models.Record{
Client: clientUUID,
Time: timeSlot,
Time: models.FromTime(timeSlot),
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
@@ -270,8 +272,8 @@ func migrateOldRecords(db *gorm.DB) error {
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, high_percentile),
NetOut: getIntPercentile(data.NetOut, 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),
@@ -292,7 +294,7 @@ func migrateOldRecords(db *gorm.DB) error {
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo).Delete(&models.Record{}).Error; err != nil {
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
+4 -4
View File
@@ -40,7 +40,7 @@ func TestCompactRecord(t *testing.T) {
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: recTime, Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
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)
@@ -65,7 +65,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
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),
@@ -100,7 +100,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
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),
@@ -119,7 +119,7 @@ func TestCompactRecord(t *testing.T) {
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
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),
+8
View File
@@ -85,6 +85,14 @@ func DeletePingRecords(id []uint) error {
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
+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=
+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")
}
+26 -10
View File
@@ -1,22 +1,38 @@
package messageSender
import (
"errors"
"github.com/komari-monitor/komari/database/config"
)
func SendTextMessage(message string) error {
cfg, _ := config.Get()
if !cfg.NotificationEnabled {
return nil
var CurrentProvider MessageSender
func init() {
CurrentProvider = &EmptyProvider{}
}
func Initialize() {
cfg, err := config.Get()
if err != nil {
CurrentProvider = &EmptyProvider{}
return
}
switch cfg.NotificationMethod {
case "none":
return nil
case "telegram":
return TelegramSendTextMessage(message)
CurrentProvider = &TelegramMessageSender{}
case "email":
CurrentProvider = &EmailMessageSender{}
case "none", "":
CurrentProvider = &EmptyProvider{}
default:
return errors.New("unsupported notification method: " + cfg.NotificationMethod)
CurrentProvider = &EmptyProvider{}
}
}
type MessageSender interface {
SendTextMessage(message, title string) error
}
func SendTextMessage(message string, title string) error {
return CurrentProvider.SendTextMessage(message, title)
}
+18 -22
View File
@@ -10,27 +10,36 @@ import (
"github.com/komari-monitor/komari/database/config"
)
func TelegramSendMessage(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 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 TelegramSendMessage(message, msgType string) error {
return nil
}
return lastErr
}
// SendTextMessage sends a text message via Telegram API
func TelegramSendTextMessage(message string) error {
return TelegramSendMessage(message, "text")
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func TelegramSendMarkdownMessage(message string) error {
return TelegramSendMessage(message, "MarkdownV2")
}
func TelegramSendHTMLMessage(message string) error {
return TelegramSendMessage(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)
}
}
+98 -52
View File
@@ -13,99 +13,145 @@ import (
"github.com/komari-monitor/komari/utils/messageSender"
)
var (
pendingOffline = make(map[string]time.Time)
mu sync.Mutex
)
// notificationState 保存单个客户端的通知状态。
// 通过在结构体中嵌入互斥锁,实现每个客户端细粒度的锁定,比全局锁更高效。
type notificationState struct {
mu sync.Mutex // 互斥锁,保护该客户端状态
pendingOfflineSince time.Time // 客户端离线的时间。为零值表示客户端在线或已发送离线通知。
isFirstConnection bool // 标记是否为首次上线连接。
}
// OfflineNotification sends an offline notification for the client if enabled and not sent in grace period
// clientStates 使用 sync.Map 实现对客户端状态的并发访问。
// 映射关系:clientID (string) -> *notificationState
var clientStates sync.Map
// 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
}
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
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) {
state.mu.Lock()
defer state.mu.Unlock()
// 检查离线状态是否仍为本次协程启动时的状态。
// 若为零值,说明客户端已重连。
// 若时间不同,说明客户端重连后又断开,已启动新计时。
if state.pendingOfflineSince.IsZero() || !state.pendingOfflineSince.Equal(startTime) {
return
}
// record notification time and clear pending
delete(pendingOffline, clientID)
// 即将发送通知,重置待通知状态。
state.pendingOfflineSince = time.Time{}
// Send notification
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
_ = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now)
// 更新数据库中的最后通知时间
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
// 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
}
// clear any pending offline debounce
mu.Lock()
delete(pendingOffline, clientID)
mu.Unlock()
// 检查客户端是否处于待离线状态。
wasPending := !state.pendingOfflineSince.IsZero()
// 上线时总是清除待离线状态。
state.pendingOfflineSince = time.Time{}
// 规则4:宽限期内重连,不通知。
if wasPending {
return
}
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
+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),