mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 00:30:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e874b5db18 | ||
|
|
23e7d97e3c | ||
|
|
a222de2c55 | ||
|
|
bb1eed1ccb | ||
|
|
c34bc8da69 | ||
|
|
249c4376f1 | ||
|
|
d36bde7d94 | ||
|
|
493d2a2707 | ||
|
|
93a9fbe5cc | ||
|
|
b070f33bf9 | ||
|
|
ee5480dba7 | ||
|
|
2e9ef3d5ba | ||
|
|
1061b84087 | ||
|
|
0dc80f7696 | ||
|
|
212e3c689b | ||
|
|
488ca451ae | ||
|
|
f50c167b37 | ||
|
|
4e91d62d48 | ||
|
|
a1b0b1e177 | ||
|
|
147dd2011a | ||
|
|
d24b71d4af | ||
|
|
483e3a94e9 | ||
|
|
e612f5ed8f | ||
|
|
78f3c8fd4b | ||
|
|
5a4e073d63 | ||
|
|
22cbfecfa9 | ||
|
|
d1b059fc2f | ||
|
|
cf5c410aa6 | ||
|
|
9c46673a1a | ||
|
|
45c27ae981 | ||
|
|
b62fb1f708 | ||
|
|
579c196643 | ||
|
|
e8da6e213a | ||
|
|
d9426e43a9 | ||
|
|
aad0274d56 | ||
|
|
d65f7ca924 | ||
|
|
a4d95640a5 | ||
|
|
adda3a2488 | ||
|
|
34654c0969 | ||
|
|
b708f9817d | ||
|
|
1210a1cbd9 | ||
|
|
24f1a673b9 | ||
|
|
a3162e1992 | ||
|
|
17f904e4ab | ||
|
|
1324f4fa21 | ||
|
|
cf15144bb9 | ||
|
|
946b27b3b8 | ||
|
|
aed5254cf0 | ||
|
|
7b8922b7ac | ||
|
|
733ad4c6c0 | ||
|
|
ef98ddd1d3 | ||
|
|
ca28c40aca | ||
|
|
0af5903bfe | ||
|
|
717b7b4a88 | ||
|
|
05eec9515b | ||
|
|
877852fc29 | ||
|
|
dbb9075a52 | ||
|
|
eafcf7fe76 | ||
|
|
094ce6c1ba | ||
|
|
2eed2cf7ba | ||
|
|
88b9d3bdac | ||
|
|
9c87227613 | ||
|
|
bc737b1ef9 | ||
|
|
911d990690 | ||
|
|
0f7cd5f438 | ||
|
|
354903b97b | ||
|
|
b432396111 | ||
|
|
491c0ad531 | ||
|
|
1540b78487 | ||
|
|
11f43b116d | ||
|
|
a0760c8e93 | ||
|
|
85ecfc3500 | ||
|
|
4c2308622a | ||
|
|
25b6a25f9a | ||
|
|
7138c22d02 | ||
|
|
a06a5c9e72 | ||
|
|
668eb63668 | ||
|
|
7d75f1ba37 | ||
|
|
2c7b59985e | ||
|
|
b616eeb2d4 | ||
|
|
0f0e359c63 | ||
|
|
1b83644788 | ||
|
|
9a9200744e | ||
|
|
e763bc12c4 | ||
|
|
8d7b47a7de |
No files matched your search
@@ -70,8 +70,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -107,4 +114,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
@@ -12,13 +12,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
|
||||
|
||||
# mkdir -p public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
@@ -26,8 +26,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -33,8 +33,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -36,8 +36,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -73,8 +73,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -14,6 +14,7 @@ komari.db
|
||||
/data
|
||||
/utils/geoip/data
|
||||
.vscode/
|
||||
/backup
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -30,3 +31,6 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
@@ -0,0 +1,27 @@
|
||||
CoreOS Project
|
||||
Copyright 2014 CoreOS, Inc
|
||||
|
||||
This product includes software developed at CoreOS, Inc.
|
||||
(http://www.coreos.com/).
|
||||
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
|
||||
Copyright 2014 gRPC authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,6 +1,7 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

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

|
||||
|
||||
@@ -8,9 +9,10 @@
|
||||
|
||||
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
|
||||
|
||||
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
|
||||
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
## Features
|
||||
|
||||
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
|
||||
- **Self-hosted**: Complete control over data privacy, easy to deploy.
|
||||
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
|
||||
@@ -18,16 +20,15 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
## Quick Start
|
||||
|
||||
### 0. One-click Deployment with Cloud Hosting
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
- Claw Run - USD 1.5/month
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 1. Use the One-click Install Script
|
||||
|
||||
Suitable for distributions using systemd (Ubuntu, Debian...).
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
@@ -35,6 +36,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker Deployment
|
||||
|
||||
1. Create a data directory:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -57,6 +59,7 @@ sudo ./install-komari.sh
|
||||
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
### 3. Binary File Deployment
|
||||
|
||||
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
|
||||
2. Run Komari:
|
||||
```bash
|
||||
@@ -68,7 +71,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
|
||||
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### Dependencies
|
||||
@@ -87,33 +89,46 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
|
||||
```bash
|
||||
Copy the static files generated in step 1 to the `/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. Run:
|
||||
3. Run:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
The default listening port is `25774`. Access `http://localhost:25774`.
|
||||
|
||||
## Frontend Development Guide
|
||||
|
||||
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## Client Agent Development Guide
|
||||
|
||||
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
## Acknowledgements
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- Thanks to myself for being so idle
|
||||
- All the developers who submitted PRs and created themes
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
|
||||
### 🚀 Sponsored by SharonNetworks
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
|
||||
|
||||
### The open source software community
|
||||
|
||||
All the developers who submitted PRs and created themes
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
+30
-5
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
@@ -37,6 +37,7 @@ var TerminalSessions = make(map[string]*TerminalSession)
|
||||
func SaveClientReportToDB() error {
|
||||
lastMinute := time.Now().Add(-time.Minute).Unix()
|
||||
var records []models.Record
|
||||
var gpuRecords []models.GPURecord
|
||||
|
||||
// 遍历所有客户端记录
|
||||
for uuid, x := range Records.Items() {
|
||||
@@ -65,10 +66,16 @@ func SaveClientReportToDB() error {
|
||||
if len(filtered) > 0 {
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
|
||||
records = append(records, r)
|
||||
|
||||
// 使用与其他数据相同的聚合逻辑处理GPU数据
|
||||
gpuAggregated := utils.AverageGPUReports(uuid, time.Now(), filtered, 0.3)
|
||||
gpuRecords = append(gpuRecords, gpuAggregated...)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入数据库前去重(client与time共同构成唯一键)
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
if len(records) > 0 {
|
||||
unique := make(map[string]models.Record)
|
||||
for _, rec := range records {
|
||||
@@ -79,13 +86,30 @@ func SaveClientReportToDB() error {
|
||||
for _, rec := range unique {
|
||||
deduped = append(deduped, rec)
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
|
||||
log.Printf("Failed to save records to database: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入GPU记录
|
||||
if len(gpuRecords) > 0 {
|
||||
// GPU记录也需要去重,防止重复插入
|
||||
gpuUnique := make(map[string]models.GPURecord)
|
||||
for _, rec := range gpuRecords {
|
||||
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
|
||||
gpuUnique[key] = rec
|
||||
}
|
||||
var gpuDeduped []models.GPURecord
|
||||
for _, rec := range gpuUnique {
|
||||
gpuDeduped = append(gpuDeduped, rec)
|
||||
}
|
||||
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
|
||||
log.Printf("Failed to save GPU records to database: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,12 +146,13 @@ func GetVersion(c *gin.Context) {
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
cfg, err := config.Get()
|
||||
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
|
||||
|
||||
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+cfg.ApiKey
|
||||
return apiKey == "Bearer "+apiKeyConfig
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -45,14 +45,14 @@ func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
conf, err := config.Get()
|
||||
PrivateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 验证私有, 如果不是私有站点,直接放行
|
||||
if !conf.PrivateSite {
|
||||
if !PrivateSite {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
+123
-149
@@ -4,7 +4,6 @@ import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,73 +16,132 @@ import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
)
|
||||
|
||||
// copyDatabaseToTemp 安全地复制数据库文件到临时目录
|
||||
// 对于SQLite数据库,会使用BACKUP命令确保数据一致性
|
||||
func copyDatabaseToTemp(dbFilePath, tempDir string) (string, error) {
|
||||
if dbFilePath == "" {
|
||||
return "", fmt.Errorf("database file path is empty")
|
||||
// copyFile 复制单个文件到目标路径(会确保父目录存在)
|
||||
func copyFile(srcPath, destPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %v", err)
|
||||
}
|
||||
|
||||
// 检查数据库文件是否存在
|
||||
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("database file does not exist: %s", dbFilePath)
|
||||
}
|
||||
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
tempDbPath := filepath.Join(tempDir, dbFileName)
|
||||
|
||||
// 如果是SQLite数据库,使用SQLite的BACKUP命令来确保一致性
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// 获取底层的SQLite连接
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get underlying database connection: %v", err)
|
||||
}
|
||||
|
||||
// 使用SQLite的BACKUP API来创建一致的备份
|
||||
backupSQL := fmt.Sprintf("BACKUP DATABASE main TO '%s'", tempDbPath)
|
||||
_, err = sqlDB.Exec(backupSQL)
|
||||
if err != nil {
|
||||
// 如果BACKUP命令失败,回退到文件复制方法
|
||||
log.Printf("SQLite BACKUP command failed, falling back to file copy: %v", err)
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
|
||||
log.Printf("Database backed up using SQLite BACKUP to: %s", tempDbPath)
|
||||
return tempDbPath, nil
|
||||
} else {
|
||||
// 对于非SQLite数据库,直接复制文件
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// copyFileToTemp 简单的文件复制到临时目录
|
||||
func copyFileToTemp(srcPath, destPath string) (string, error) {
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source file: %v", err)
|
||||
return fmt.Errorf("failed to open source file: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create destination file: %v", err)
|
||||
return fmt.Errorf("failed to create destination file: %v", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to copy file: %v", err)
|
||||
if _, err = io.Copy(dest, src); err != nil {
|
||||
return fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDataToTempExcludingDB 将 ./data 下除了 .db/.db-wal/.db-shm 之外的所有文件复制到临时目录
|
||||
func copyDataToTempExcludingDB(tempDir string) error {
|
||||
dataRoot := "./data"
|
||||
|
||||
// 如果 data 目录不存在,视为无文件可复制
|
||||
if stat, err := os.Stat(dataRoot); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("stat data dir: %v", err)
|
||||
} else if !stat.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", dataRoot)
|
||||
}
|
||||
|
||||
log.Printf("Database file copied to: %s", destPath)
|
||||
return destPath, nil
|
||||
return filepath.Walk(dataRoot, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dataRoot, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 跳过数据库相关文件
|
||||
name := info.Name()
|
||||
if strings.HasSuffix(strings.ToLower(name), ".db") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".db-wal") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".db-shm") {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := filepath.Join(tempDir, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dst, 0o755)
|
||||
}
|
||||
return copyFile(p, dst)
|
||||
})
|
||||
}
|
||||
|
||||
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径
|
||||
func backupSQLiteTo(destDBPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for db: %v", err)
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database connection: %v", err)
|
||||
}
|
||||
|
||||
safePath := strings.ReplaceAll(destDBPath, "'", "''")
|
||||
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
|
||||
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
|
||||
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
|
||||
func DownloadBackup(c *gin.Context) {
|
||||
// 1) 创建临时目录
|
||||
tempDir, err := os.MkdirTemp("", "komari-backup-*")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
|
||||
if err := copyDataToTempExcludingDB(tempDir); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 处理数据库备份 -> 临时目录/komari.db
|
||||
destDB := filepath.Join(tempDir, "komari.db")
|
||||
dbFilePath := flags.DatabaseFile
|
||||
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
if err := backupSQLiteTo(destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
|
||||
return
|
||||
}
|
||||
} else if dbFilePath != "" {
|
||||
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
|
||||
if _, err := os.Stat(dbFilePath); err == nil {
|
||||
if err := copyFile(dbFilePath, destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
|
||||
return
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 开始写出 ZIP(以临时目录为根)
|
||||
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
|
||||
c.Writer.Header().Set("Content-Type", "application/zip")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
|
||||
@@ -91,51 +149,34 @@ func DownloadBackup(c *gin.Context) {
|
||||
zipWriter := zip.NewWriter(c.Writer)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// 创建临时目录用于存放数据库备份
|
||||
tempDir, err := os.MkdirTemp("", "komari-backup-*")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // 清理临时目录
|
||||
|
||||
err = filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
// 写入临时目录里的内容
|
||||
err = filepath.Walk(tempDir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取在 zip 包内的相对路径
|
||||
relPath, err := filepath.Rel("./data", filePath)
|
||||
rel, err := filepath.Rel(tempDir, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跳过根目录本身,只打包内容
|
||||
if relPath == "." {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// zip 内路径统一用正斜杠
|
||||
zipPath := filepath.ToSlash(relPath)
|
||||
|
||||
// zip 内路径统一正斜杠
|
||||
zipPath := filepath.ToSlash(rel)
|
||||
if info.IsDir() {
|
||||
// 在 zip 中创建目录项
|
||||
_, err = zipWriter.CreateHeader(&zip.FileHeader{
|
||||
_, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath + "/",
|
||||
Method: zip.Deflate, // zip.Store 不压缩
|
||||
Method: zip.Deflate,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// 普通文件,写入 zip
|
||||
file, err := os.Open(filePath)
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
defer f.Close()
|
||||
w, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: zipPath,
|
||||
Method: zip.Deflate,
|
||||
Modified: info.ModTime(),
|
||||
@@ -143,78 +184,15 @@ func DownloadBackup(c *gin.Context) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving data folder: %v", err))
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 ./data 和数据库文件的绝对路径以进行可靠的比较
|
||||
dataAbsPath, err := filepath.Abs("./data")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error getting absolute path for data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
dbFilePath := flags.DatabaseFile
|
||||
dbAbsPath, err := filepath.Abs(dbFilePath)
|
||||
if err != nil {
|
||||
// 如果无法解析数据库路径,可能是一个配置问题,但我们记录并跳过,避免备份失败
|
||||
log.Printf("Could not determine absolute path for database file '%s', skipping explicit addition. Error: %v\n", dbFilePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
|
||||
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
|
||||
// 使用安全的数据库复制方法
|
||||
tempDbPath, err := copyDatabaseToTemp(dbFilePath, tempDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 将临时数据库文件添加到zip
|
||||
dbInfo, err := os.Stat(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: filepath.Base(dbFilePath), // 使用原始数据库文件名
|
||||
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)
|
||||
}
|
||||
|
||||
// 添加备份标记文件
|
||||
// 5) 追加备份标记文件(放在 zip 根目录)
|
||||
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
|
||||
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "komari-backup-markup",
|
||||
@@ -225,12 +203,8 @@ func DownloadBackup(c *gin.Context) {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = markupWriter.Write([]byte(markupContent))
|
||||
if err != nil {
|
||||
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// zipWriter.Close() 由 defer 自动调用
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
@@ -50,9 +50,10 @@ func SetMessageSenderProvider(c *gin.Context) {
|
||||
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
|
||||
|
||||
// 正在使用,重载
|
||||
if cfg.NotificationMethod == senderConfig.Name {
|
||||
if NotificationMethod == senderConfig.Name {
|
||||
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
|
||||
|
||||
+3
-3
@@ -3,9 +3,9 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
@@ -80,9 +80,9 @@ func SetOidcProvider(c *gin.Context) {
|
||||
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
OAuthProvider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
|
||||
// 正在使用,重载
|
||||
if cfg.OAuthProvider == oidcConfig.Name {
|
||||
if OAuthProvider == oidcConfig.Name {
|
||||
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
|
||||
|
||||
+5
-23
@@ -1,12 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
|
||||
@@ -15,20 +12,10 @@ import (
|
||||
|
||||
// GetSettings 获取自定义配置
|
||||
func GetSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
cst, err := config.GetAll()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
//override
|
||||
cst = models.Config{Sitename: "Komari"}
|
||||
cst.ID = 1
|
||||
config.Save(cst)
|
||||
api.RespondSuccess(c, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
api.RespondError(c, 500, "Failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
@@ -41,8 +28,7 @@ func EditSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
if err := config.SetMany(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -50,10 +36,6 @@ func EditSettings(c *gin.Context) {
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
ignoredKeys := []string{"id", "updated_at"}
|
||||
if contains(ignoredKeys, key) {
|
||||
continue
|
||||
}
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
|
||||
@@ -5,14 +5,17 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
func TestSendMessage(c *gin.Context) {
|
||||
message := "Komari test message."
|
||||
err := messageSender.SendTextMessage(message, "Komari Test Notification")
|
||||
err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: "Test",
|
||||
Message: "This is a test message from Komari.",
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to send message: "+err.Error())
|
||||
return
|
||||
@@ -29,12 +32,12 @@ func TestGeoIp(c *gin.Context) {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := config.Get()
|
||||
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
if !cfg {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
+14
-9
@@ -14,9 +14,10 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
)
|
||||
|
||||
// UploadTheme 上传主题
|
||||
@@ -69,6 +70,15 @@ func ListThemes(c *gin.Context) {
|
||||
}
|
||||
|
||||
var themes []models.Theme
|
||||
defaultTheme, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err == nil {
|
||||
dt := models.Theme{}
|
||||
err := json.Unmarshal(defaultTheme, &dt)
|
||||
if err == nil {
|
||||
themes = append(themes, dt)
|
||||
}
|
||||
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
|
||||
@@ -133,12 +143,7 @@ func SetTheme(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
updateData := map[string]interface{}{
|
||||
"theme": themeName,
|
||||
}
|
||||
|
||||
if err := config.Update(updateData); err != nil {
|
||||
if err := config.Set("theme", themeName); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -591,8 +596,8 @@ func UpdateTheme(c *gin.Context) {
|
||||
|
||||
func UpdateThemeSettings(c *gin.Context) {
|
||||
theme := c.Query("theme")
|
||||
if theme == "" || theme == "default" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
|
||||
if theme == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+49
-97
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
// 只有一个备份恢复操作在进行
|
||||
@@ -43,8 +42,14 @@ func UploadBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时文件保存上传的zip
|
||||
tempFile, err := os.CreateTemp("", "backup-*.zip")
|
||||
// 确保data目录存在
|
||||
if err := os.MkdirAll("./data", 0755); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
|
||||
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
|
||||
return
|
||||
@@ -61,112 +66,59 @@ func UploadBackup(c *gin.Context) {
|
||||
}
|
||||
tempFile.Close() // 关闭文件以便后续操作
|
||||
|
||||
// 打开zip文件准备解压
|
||||
zipReader, err := zip.OpenReader(tempFilePath)
|
||||
if err != nil {
|
||||
// 基础校验:检查是否包含标记文件
|
||||
if zr, err := zip.OpenReader(tempFilePath); err == nil {
|
||||
hasMarkup := false
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "komari-backup-markup" {
|
||||
hasMarkup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
zr.Close()
|
||||
if !hasMarkup {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
|
||||
return
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
// 检查是否包含备份标记文件
|
||||
hasMarkupFile := false
|
||||
for _, zipFile := range zipReader.File {
|
||||
if zipFile.Name == "komari-backup-markup" {
|
||||
hasMarkupFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMarkupFile {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保data目录存在
|
||||
if err := os.MkdirAll("./data", 0755); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取数据库文件名
|
||||
dbFileName := filepath.Base(flags.DatabaseFile)
|
||||
|
||||
// 解压文件
|
||||
for _, zipFile := range zipReader.File {
|
||||
// 检查文件路径是否安全(防止路径遍历攻击)
|
||||
filePath := zipFile.Name
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("Potentially unsafe path in zip: %s, skipping", filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过备份标记文件
|
||||
if filePath == "komari-backup-markup" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 确定目标路径
|
||||
var destPath string
|
||||
if filePath == dbFileName {
|
||||
// 如果是数据库文件,恢复到数据库文件路径
|
||||
destPath = flags.DatabaseFile
|
||||
} else {
|
||||
// 其他文件恢复到data目录
|
||||
destPath = filepath.Join("./data", filePath)
|
||||
}
|
||||
|
||||
// 如果是目录,创建目录
|
||||
if zipFile.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||
log.Printf("Error creating directory %s: %v", destPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 确保目标文件的目录存在
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
log.Printf("Error creating directory %s: %v", destDir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 打开zip中的文件
|
||||
srcFile, err := zipFile.Open()
|
||||
if err != nil {
|
||||
log.Printf("Error opening file from zip %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建目标文件
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
srcFile.Close()
|
||||
log.Printf("Error creating file %s: %v", destPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 复制内容
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
log.Printf("Error extracting file %s: %v", destPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 保持原始文件的修改时间
|
||||
if err := os.Chtimes(destPath, zipFile.Modified, zipFile.Modified); err != nil {
|
||||
log.Printf("Error setting file time for %s: %v", destPath, err)
|
||||
// 将校验通过的临时文件移动到固定路径 ./data/backup.zip
|
||||
finalPath := filepath.Join(".", "data", "backup.zip")
|
||||
// 如存在旧文件,先删除
|
||||
_ = os.Remove(finalPath)
|
||||
if err := os.Rename(tempFilePath, finalPath); err != nil {
|
||||
// fallback:拷贝
|
||||
in, err2 := os.Open(tempFilePath)
|
||||
if err2 != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
out, err2 := os.Create(finalPath)
|
||||
if err2 != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
|
||||
return
|
||||
}
|
||||
if _, err2 = io.Copy(out, in); err2 != nil {
|
||||
out.Close()
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
|
||||
return
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
|
||||
// 返回:已保存备份,重启后将自动恢复
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "success",
|
||||
"message": "Backup restored successfully. The service will restart shortly.",
|
||||
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
|
||||
"path": "./data/backup.zip",
|
||||
})
|
||||
|
||||
go func() {
|
||||
log.Println("Backup restored, restarting service in 2 seconds...")
|
||||
log.Println("Backup uploaded, restarting service in 2 seconds to apply on startup...")
|
||||
time.Sleep(2 * time.Second)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
@@ -3,8 +3,8 @@ package client
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
@@ -14,15 +14,14 @@ func RegisterClient(c *gin.Context) {
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Get()
|
||||
AutoDiscoveryKey, err := config.GetAs[string](config.AutoDiscoveryKeyKey, "")
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
api.RespondError(c, 500, "Failed to get AutoDiscovery Key: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.AutoDiscoveryKey == "" ||
|
||||
len(cfg.AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+cfg.AutoDiscoveryKey != auth {
|
||||
if AutoDiscoveryKey == "" ||
|
||||
len(AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+AutoDiscoveryKey != auth {
|
||||
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
|
||||
@@ -3,8 +3,8 @@ package client
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,7 +60,7 @@ func UploadBasicInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
|
||||
+56
-10
@@ -11,22 +11,64 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
|
||||
// group: 调用者的权限分组 (guest/client/admin)
|
||||
// method: RPC 方法名 (如 "common:getNodes")
|
||||
// params: 方法参数
|
||||
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
|
||||
// 检查私有站点
|
||||
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
|
||||
|
||||
if PrivateSite && group == "guest" {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
|
||||
}
|
||||
|
||||
// 解析方法命名空间
|
||||
fc := strings.Split(method, ":")
|
||||
namespace := "common"
|
||||
if len(fc) >= 2 {
|
||||
namespace = fc[0]
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
allowed := false
|
||||
switch namespace {
|
||||
case "guest", "", "rpc", "common":
|
||||
allowed = true
|
||||
case "client":
|
||||
if group == "client" || group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
case "admin":
|
||||
if group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
|
||||
}
|
||||
|
||||
// 构建 context meta
|
||||
meta := &rpc.ContextMeta{Permission: group}
|
||||
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
|
||||
}
|
||||
|
||||
// Json Rpc2 over websocket, /api/rpc2
|
||||
func OnRpcRequest(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey)
|
||||
|
||||
// 如果是 WebSocket Upgrade (常见于 GET) 则走原有逻辑
|
||||
if strings.EqualFold(c.GetHeader("Upgrade"), "websocket") || strings.Contains(strings.ToLower(c.GetHeader("Connection")), "upgrade") {
|
||||
// GET -> WebSocket
|
||||
if c.Request.Method == http.MethodGet {
|
||||
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
@@ -36,7 +78,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c, cfg)
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
defer conn.Close()
|
||||
for {
|
||||
@@ -77,7 +119,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, jerr.Response())
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c, cfg)
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
// 批量
|
||||
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
|
||||
@@ -118,7 +160,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
}
|
||||
|
||||
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
|
||||
func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
|
||||
func detectPermissionGroup(c *gin.Context) string {
|
||||
permissionGroup := "guest"
|
||||
token := c.Query("Authorization")
|
||||
if _, err := clients.GetClientUUIDByToken(token); err == nil {
|
||||
@@ -130,7 +172,11 @@ func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
|
||||
}
|
||||
}
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if apiKey == "Bearer "+cfg.ApiKey {
|
||||
if len(apiKey) <= len("Bearer ") {
|
||||
return permissionGroup
|
||||
}
|
||||
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
|
||||
permissionGroup = "admin"
|
||||
}
|
||||
return permissionGroup
|
||||
|
||||
+199
-27
@@ -2,18 +2,171 @@ package jsonRpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
// pingstats:<uuid>
|
||||
var pingStatsCache = cache.New(1*time.Minute, 2*time.Minute)
|
||||
|
||||
type pingStat struct {
|
||||
Name string `json:"name"`
|
||||
Latest int `json:"latest"`
|
||||
Avg int `json:"avg"`
|
||||
Tail float64 `json:"tail"` // (P99-P50)/P50
|
||||
Loss float64 `json:"loss"` // 丢包率 %
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// getPingStatsForNode 计算并缓存节点最近 1 小时 ping 统计
|
||||
func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pingStat {
|
||||
if uuid == "" {
|
||||
return map[string]pingStat{}
|
||||
}
|
||||
key := fmt.Sprintf("pingstats:%s", uuid)
|
||||
if v, ok := pingStatsCache.Get(key); ok {
|
||||
if m, ok2 := v.(map[string]pingStat); ok2 {
|
||||
return m
|
||||
}
|
||||
}
|
||||
// 筛选属于该节点的任务
|
||||
assigned := make([]models.PingTask, 0, 4)
|
||||
for _, t := range pingTasks {
|
||||
for _, c := range t.Clients {
|
||||
if c == uuid {
|
||||
assigned = append(assigned, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(assigned) == 0 {
|
||||
empty := map[string]pingStat{}
|
||||
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
|
||||
return empty
|
||||
}
|
||||
end := time.Now()
|
||||
start := end.Add(-1 * time.Hour)
|
||||
recs, err := tasks.GetPingRecords(uuid, -1, start, end)
|
||||
if err != nil || len(recs) == 0 {
|
||||
empty := map[string]pingStat{}
|
||||
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
|
||||
return empty
|
||||
}
|
||||
grouped := make(map[uint][]models.PingRecord)
|
||||
for _, r := range recs {
|
||||
for _, t := range assigned {
|
||||
if r.TaskId == t.Id {
|
||||
grouped[r.TaskId] = append(grouped[r.TaskId], r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make(map[string]pingStat, len(grouped))
|
||||
for _, t := range assigned {
|
||||
records := grouped[t.Id]
|
||||
if len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
latest := -1
|
||||
var latestTs time.Time
|
||||
values := make([]int, 0, len(records))
|
||||
sum := 0
|
||||
valid := 0
|
||||
total := 0
|
||||
lossCount := 0
|
||||
minLat := 0
|
||||
maxLat := 0
|
||||
for _, r := range records {
|
||||
total++
|
||||
if r.Value < 0 { // 丢包
|
||||
lossCount++
|
||||
continue
|
||||
}
|
||||
values = append(values, r.Value)
|
||||
sum += r.Value
|
||||
valid++
|
||||
if minLat == 0 || r.Value < minLat {
|
||||
minLat = r.Value
|
||||
}
|
||||
if r.Value > maxLat {
|
||||
maxLat = r.Value
|
||||
}
|
||||
ts := r.Time.ToTime()
|
||||
if latestTs.IsZero() || ts.After(latestTs) {
|
||||
latestTs = ts
|
||||
latest = r.Value
|
||||
}
|
||||
}
|
||||
avg := 0
|
||||
if valid > 0 {
|
||||
avg = sum / valid
|
||||
}
|
||||
p50, p99 := 0, 0
|
||||
if len(values) > 0 {
|
||||
sort.Ints(values)
|
||||
percentile := func(vals []int, pct float64) int {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
if pct <= 0 {
|
||||
return vals[0]
|
||||
}
|
||||
if pct >= 1 {
|
||||
return vals[len(vals)-1]
|
||||
}
|
||||
pos := (float64(len(vals) - 1)) * pct
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return vals[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
v := float64(vals[lo]) + (float64(vals[hi])-float64(vals[lo]))*frac
|
||||
return int(math.Round(v))
|
||||
}
|
||||
p50 = percentile(values, 0.50)
|
||||
p99 = percentile(values, 0.99)
|
||||
}
|
||||
tail := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
tail = float64(p99-p50) / float64(p50)
|
||||
}
|
||||
lossRate := 0.0
|
||||
if total > 0 {
|
||||
lossRate = float64(lossCount) / float64(total) * 100
|
||||
}
|
||||
result[fmt.Sprintf("%d", t.Id)] = pingStat{
|
||||
Name: t.Name,
|
||||
Latest: latest,
|
||||
Avg: avg,
|
||||
Tail: tail,
|
||||
Loss: lossRate,
|
||||
Min: minLat,
|
||||
Max: maxLat,
|
||||
}
|
||||
}
|
||||
pingStatsCache.Set(key, result, cache.DefaultExpiration)
|
||||
return result
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterWithGroupAndMeta("getNodes", "common",
|
||||
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
@@ -76,6 +229,7 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
}
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
SendIpAddrToGuest, _ := config.GetAs[bool](config.SendIpAddrToGuestKey)
|
||||
if meta.Permission != "admin" {
|
||||
// 过滤 Hidden 节点并隐藏敏感字段
|
||||
filtered := make([]models.Client, 0, len(cinfo))
|
||||
@@ -83,8 +237,18 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
if node.Hidden { // 非 admin 不显示隐藏节点
|
||||
continue
|
||||
}
|
||||
node.IPv4 = ""
|
||||
node.IPv6 = ""
|
||||
if SendIpAddrToGuest {
|
||||
if node.IPv4 != "" {
|
||||
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
|
||||
}
|
||||
if node.IPv6 != "" {
|
||||
node.IPv6 = strings.Split(node.IPv6, ":")[0] + ":*:*:*:*:*:*:*"
|
||||
}
|
||||
} else {
|
||||
node.IPv4 = ""
|
||||
node.IPv6 = ""
|
||||
}
|
||||
|
||||
node.Remark = ""
|
||||
node.Version = ""
|
||||
node.Token = ""
|
||||
@@ -158,41 +322,47 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
|
||||
}
|
||||
|
||||
type recordLike struct {
|
||||
Client string `json:"client"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Cpu float32 `json:"cpu"`
|
||||
Gpu float32 `json:"gpu"`
|
||||
Ram int64 `json:"ram"`
|
||||
RamTotal int64 `json:"ram_total"`
|
||||
Swap int64 `json:"swap"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
Load float32 `json:"load"`
|
||||
Load5 float32 `json:"load5"`
|
||||
Load15 float32 `json:"load15"`
|
||||
Temp float32 `json:"temp"`
|
||||
Disk int64 `json:"disk"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
NetIn int64 `json:"net_in"`
|
||||
NetOut int64 `json:"net_out"`
|
||||
NetTotalUp int64 `json:"net_total_up"`
|
||||
NetTotalDown int64 `json:"net_total_down"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
Online bool `json:"online"`
|
||||
Client string `json:"client"`
|
||||
Time models.LocalTime `json:"time"`
|
||||
Cpu float32 `json:"cpu"`
|
||||
Gpu float32 `json:"gpu"`
|
||||
Ram int64 `json:"ram"`
|
||||
RamTotal int64 `json:"ram_total"`
|
||||
Swap int64 `json:"swap"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
Load float32 `json:"load"`
|
||||
Load5 float32 `json:"load5"`
|
||||
Load15 float32 `json:"load15"`
|
||||
Temp float32 `json:"temp"`
|
||||
Disk int64 `json:"disk"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
NetIn int64 `json:"net_in"`
|
||||
NetOut int64 `json:"net_out"`
|
||||
NetTotalUp int64 `json:"net_total_up"`
|
||||
NetTotalDown int64 `json:"net_total_down"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
Online bool `json:"online"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Ping map[string]pingStat `json:"ping"`
|
||||
}
|
||||
|
||||
respMap := make(map[string]recordLike, len(latest))
|
||||
|
||||
// 预取所有 ping 任务
|
||||
pingTasks, _ := tasks.GetAllPingTasks()
|
||||
|
||||
appendOne := func(uuid string, rep *common.Report) {
|
||||
if rep == nil {
|
||||
return
|
||||
}
|
||||
// time 使用 UpdatedAt
|
||||
stats := getPingStatsForNode(uuid, pingTasks)
|
||||
rl := recordLike{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(rep.UpdatedAt),
|
||||
Cpu: float32(rep.CPU.Usage),
|
||||
Gpu: 0, // 暂无实时 GPU 数据
|
||||
Gpu: 0,
|
||||
Ram: rep.Ram.Used,
|
||||
RamTotal: rep.Ram.Total,
|
||||
Swap: rep.Swap.Used,
|
||||
@@ -200,7 +370,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
|
||||
Load: float32(rep.Load.Load1),
|
||||
Load5: float32(rep.Load.Load5),
|
||||
Load15: float32(rep.Load.Load15),
|
||||
Temp: 0, // 没有温度字段
|
||||
Temp: 0,
|
||||
Disk: rep.Disk.Used,
|
||||
DiskTotal: rep.Disk.Total,
|
||||
NetIn: rep.Network.Down,
|
||||
@@ -211,6 +381,8 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
|
||||
Connections: rep.Connections.TCP + rep.Connections.UDP,
|
||||
ConnectionsUdp: rep.Connections.UDP,
|
||||
Online: onlineSet[uuid],
|
||||
Uptime: rep.Uptime,
|
||||
Ping: stats,
|
||||
}
|
||||
respMap[uuid] = rl
|
||||
}
|
||||
|
||||
+187
-84
@@ -2,6 +2,7 @@ package jsonRpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -225,7 +226,7 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
Count int `json:"count"`
|
||||
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
|
||||
Records []RecordsResp `json:"records"`
|
||||
Tasks []map[string]any `json:"tasks,omitempty"`
|
||||
Tasks []map[string]any `json:"tasks"`
|
||||
From models.LocalTime `json:"from"`
|
||||
To models.LocalTime `json:"to"`
|
||||
}
|
||||
@@ -282,101 +283,189 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
}
|
||||
}
|
||||
|
||||
// tasks summary
|
||||
if params.UUID != "" || taskId != -1 {
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
|
||||
// tasks summary (always included for ping type; do not expose target field)
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
|
||||
}
|
||||
toList := make([]map[string]any, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
if taskId != -1 && t.Id != uint(taskId) {
|
||||
continue
|
||||
}
|
||||
tlist := make([]map[string]any, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
if taskId != -1 && t.Id != uint(taskId) {
|
||||
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
|
||||
assigned := false
|
||||
for _, c := range t.Clients {
|
||||
if c == params.UUID {
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assigned {
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" {
|
||||
// check assignment
|
||||
assigned := false
|
||||
for _, c := range t.Clients {
|
||||
if c == params.UUID {
|
||||
assigned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assigned {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
lossCount := 0
|
||||
minLat := 0
|
||||
maxLat := 0
|
||||
sum := 0
|
||||
valid := 0
|
||||
for _, r := range recs {
|
||||
if r.TaskId != t.Id {
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" && r.Client != params.UUID {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.Value < 0 {
|
||||
lossCount++
|
||||
} else {
|
||||
valid++
|
||||
sum += r.Value
|
||||
if minLat == 0 || r.Value < minLat {
|
||||
minLat = r.Value
|
||||
}
|
||||
if r.Value > maxLat {
|
||||
maxLat = r.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lossRate := 0.0
|
||||
if total > 0 {
|
||||
lossRate = float64(lossCount) / float64(total) * 100
|
||||
}
|
||||
avg := 0
|
||||
if valid > 0 {
|
||||
avg = sum / valid
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"interval": t.Interval,
|
||||
"loss": lossRate,
|
||||
"min": minLat,
|
||||
"max": maxLat,
|
||||
"avg": avg,
|
||||
"total": total,
|
||||
}
|
||||
if params.UUID == "" && taskId != -1 {
|
||||
info["clients"] = t.Clients
|
||||
}
|
||||
tlist = append(tlist, info)
|
||||
}
|
||||
response.Tasks = tlist
|
||||
total := 0
|
||||
lossCount := 0
|
||||
minLat := 0
|
||||
maxLat := 0
|
||||
sum := 0
|
||||
valid := 0
|
||||
latestVal := -1
|
||||
var latestTs time.Time
|
||||
// 收集该任务的所有有效(非丢包)延迟值以计算百分位
|
||||
latencies := make([]int, 0, 64)
|
||||
for _, r := range recs {
|
||||
if r.TaskId != t.Id {
|
||||
continue
|
||||
}
|
||||
if params.UUID != "" && r.Client != params.UUID {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if r.Value < 0 { // 丢包
|
||||
lossCount++
|
||||
continue
|
||||
}
|
||||
valid++
|
||||
sum += r.Value
|
||||
latencies = append(latencies, r.Value)
|
||||
if minLat == 0 || r.Value < minLat {
|
||||
minLat = r.Value
|
||||
}
|
||||
if r.Value > maxLat {
|
||||
maxLat = r.Value
|
||||
}
|
||||
// track latest non-negative value
|
||||
ts := r.Time.ToTime()
|
||||
if latestTs.IsZero() || ts.After(latestTs) {
|
||||
latestTs = ts
|
||||
latestVal = r.Value
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 P50 / P99
|
||||
p50 := 0
|
||||
p99 := 0
|
||||
if len(latencies) > 0 {
|
||||
sort.Ints(latencies)
|
||||
getPercentileInt := func(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if percentile <= 0 {
|
||||
return values[0]
|
||||
}
|
||||
if percentile >= 1 {
|
||||
return values[len(values)-1]
|
||||
}
|
||||
pos := (float64(len(values) - 1)) * percentile
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return values[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
v := float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac
|
||||
return int(math.Round(v))
|
||||
}
|
||||
p50 = getPercentileInt(latencies, 0.50)
|
||||
p99 = getPercentileInt(latencies, 0.99)
|
||||
}
|
||||
ratio := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
jitterMs := float64(p99 - p50)
|
||||
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
|
||||
ratio = jitterMs / adjustedBase
|
||||
}
|
||||
lossRate := 0.0
|
||||
if total > 0 {
|
||||
lossRate = float64(lossCount) / float64(total) * 100
|
||||
}
|
||||
avg := 0
|
||||
if valid > 0 {
|
||||
avg = sum / valid
|
||||
}
|
||||
info := map[string]any{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"type": t.Type,
|
||||
"interval": t.Interval,
|
||||
"loss": lossRate,
|
||||
"min": minLat,
|
||||
"max": maxLat,
|
||||
"avg": avg,
|
||||
"latest": latestVal,
|
||||
"total": total,
|
||||
"p50": p50,
|
||||
"p99": p99,
|
||||
"p99_p50_ratio": ratio,
|
||||
}
|
||||
if params.UUID == "" && taskId != -1 { // retain existing behavior of exposing clients only when filtering by task
|
||||
info["clients"] = t.Clients
|
||||
}
|
||||
toList = append(toList, info)
|
||||
}
|
||||
response.Tasks = toList
|
||||
// apply maxCount for ping
|
||||
maxCount := params.MaxCount
|
||||
if maxCount == 0 {
|
||||
maxCount = 4000
|
||||
}
|
||||
if maxCount != -1 && len(response.Records) > maxCount {
|
||||
// sort by time asc then downsample uniformly
|
||||
sort.Slice(response.Records, func(i, j int) bool {
|
||||
return response.Records[i].Time.ToTime().Before(response.Records[j].Time.ToTime())
|
||||
})
|
||||
// local downsample for ping record slice
|
||||
sample := func(in []RecordsResp, k int) []RecordsResp {
|
||||
// group records by TaskId for proportional downsampling
|
||||
taskGroups := make(map[uint][]RecordsResp)
|
||||
for _, r := range response.Records {
|
||||
taskGroups[r.TaskId] = append(taskGroups[r.TaskId], r)
|
||||
}
|
||||
|
||||
// sort each group by time
|
||||
for taskId := range taskGroups {
|
||||
sort.Slice(taskGroups[taskId], func(i, j int) bool {
|
||||
return taskGroups[taskId][i].Time.ToTime().Before(taskGroups[taskId][j].Time.ToTime())
|
||||
})
|
||||
}
|
||||
|
||||
// calculate proportional allocation for each task
|
||||
type taskMeta struct {
|
||||
taskId uint
|
||||
length int
|
||||
}
|
||||
groupsMeta := make([]taskMeta, 0, len(taskGroups))
|
||||
for taskId, records := range taskGroups {
|
||||
groupsMeta = append(groupsMeta, taskMeta{
|
||||
taskId: taskId,
|
||||
length: len(records),
|
||||
})
|
||||
}
|
||||
|
||||
// use existing allocateTargets function (create compatible struct)
|
||||
strTargets := allocateTargets(
|
||||
func() []struct {
|
||||
name string
|
||||
length int
|
||||
} {
|
||||
result := make([]struct {
|
||||
name string
|
||||
length int
|
||||
}, len(groupsMeta))
|
||||
for i, meta := range groupsMeta {
|
||||
result[i] = struct {
|
||||
name string
|
||||
length int
|
||||
}{name: fmt.Sprintf("%d", meta.taskId), length: meta.length}
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
maxCount,
|
||||
)
|
||||
|
||||
// downsample each task group
|
||||
downsampledRecords := make([]RecordsResp, 0, maxCount)
|
||||
samplePingRecords := func(in []RecordsResp, k int) []RecordsResp {
|
||||
n := len(in)
|
||||
if k <= 0 || n == 0 {
|
||||
return in[:0]
|
||||
return []RecordsResp{}
|
||||
}
|
||||
if k >= n {
|
||||
return in
|
||||
@@ -397,7 +486,15 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
|
||||
}
|
||||
return out
|
||||
}
|
||||
response.Records = sample(response.Records, maxCount)
|
||||
|
||||
for taskId, records := range taskGroups {
|
||||
targetKey := fmt.Sprintf("%d", taskId)
|
||||
targetCount := strTargets[targetKey]
|
||||
sampled := samplePingRecords(records, targetCount)
|
||||
downsampledRecords = append(downsampledRecords, sampled...)
|
||||
}
|
||||
|
||||
response.Records = downsampledRecords
|
||||
}
|
||||
response.Count = len(response.Records)
|
||||
// sort by time asc
|
||||
@@ -641,12 +738,18 @@ type flatRecord struct {
|
||||
Process *int `json:"process,omitempty"`
|
||||
Connections *int `json:"connections,omitempty"`
|
||||
ConnectionsUdp *int `json:"connections_udp,omitempty"`
|
||||
Uptime *int64 `json:"uptime,omitempty"`
|
||||
}
|
||||
|
||||
func filterRecordsByLoadType(recs []models.Record, loadType string) []flatRecord {
|
||||
out := make([]flatRecord, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
fr := flatRecord{Client: r.Client, Time: r.Time}
|
||||
// always include uptime when present
|
||||
//if r.Uptime != 0 {
|
||||
// v := r.Uptime
|
||||
// fr.Uptime = &v
|
||||
//}
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
v := r.Cpu
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -19,8 +19,8 @@ type LoginRequest struct {
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := config.Get()
|
||||
if conf.DisablePasswordLogin {
|
||||
DisablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
|
||||
if DisablePasswordLogin {
|
||||
RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
+4
-4
@@ -5,17 +5,17 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
if !cfg.OAuthEnabled {
|
||||
OAuthEnabled, _ := config.GetAs[bool](config.OAuthEnabledKey, false)
|
||||
if !OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func OAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
|
||||
c.Redirect(302, "/manage")
|
||||
c.Redirect(302, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+1616
File diff suppressed because it is too large.
Load diff
+49
-13
@@ -59,37 +59,73 @@ func GetRecordsByUUID(c *gin.Context) {
|
||||
|
||||
// 验证 load_type 参数
|
||||
validLoadTypes := map[string]bool{
|
||||
"cpu": true, "gpu": true, "ram": true, "swap": true,
|
||||
"cpu": true, "ram": true, "swap": true,
|
||||
"load": true, "temp": true, "disk": true, "network": true,
|
||||
"process": true, "connections": true, "all": true, "": true,
|
||||
}
|
||||
|
||||
|
||||
if !validLoadTypes[loadType] {
|
||||
api.RespondError(c, 400, "Invalid load_type parameter")
|
||||
return
|
||||
}
|
||||
|
||||
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 load_type 过滤返回的数据
|
||||
// 准备基本响应
|
||||
response := gin.H{
|
||||
"records": clientRecords,
|
||||
"count": len(clientRecords),
|
||||
}
|
||||
|
||||
// 如果有load_type过滤,应用过滤
|
||||
if loadType != "" && loadType != "all" {
|
||||
filteredRecords := filterRecordsByLoadType(records, loadType)
|
||||
api.RespondSuccess(c, gin.H{
|
||||
filteredRecords := filterRecordsByLoadType(clientRecords, loadType)
|
||||
response = gin.H{
|
||||
"records": filteredRecords,
|
||||
"count": len(filteredRecords),
|
||||
"load_type": loadType,
|
||||
})
|
||||
} else {
|
||||
// 返回所有数据(向后兼容)
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"records": records,
|
||||
"count": len(records),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 自动检测是否有GPU数据并附加到响应中
|
||||
if loadType == "" || loadType == "all" || loadType == "gpu" {
|
||||
gpuRecords, err := records.GetGPURecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
|
||||
if err == nil && len(gpuRecords) > 0 {
|
||||
// 按设备索引分组数据,构建简化的设备结构
|
||||
gpuDevices := make(map[string]interface{})
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
deviceKey := strconv.Itoa(record.DeviceIndex)
|
||||
|
||||
// 如果设备还没有初始化,创建设备信息
|
||||
if gpuDevices[deviceKey] == nil {
|
||||
gpuDevices[deviceKey] = gin.H{
|
||||
"device_index": record.DeviceIndex,
|
||||
"device_name": record.DeviceName,
|
||||
"records": []models.GPURecord{},
|
||||
}
|
||||
}
|
||||
|
||||
// 添加记录到设备
|
||||
device := gpuDevices[deviceKey].(gin.H)
|
||||
records := device["records"].([]models.GPURecord)
|
||||
device["records"] = append(records, record)
|
||||
gpuDevices[deviceKey] = device
|
||||
}
|
||||
|
||||
// 添加优化后的GPU数据结构到响应
|
||||
response["gpu_devices"] = gpuDevices
|
||||
response["has_gpu_data"] = true
|
||||
} else {
|
||||
response["has_gpu_data"] = false
|
||||
}
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, response)
|
||||
}
|
||||
|
||||
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
@@ -20,10 +20,10 @@ func GetClients(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey, false)
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
@@ -87,8 +87,8 @@ func GetClients(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 已建立连接的客户端uuid列表
|
||||
for key := range ws.GetConnectedClients() {
|
||||
// 在线客户端uuid列表(WebSocket 与非 WebSocket)
|
||||
for _, key := range ws.GetAllOnlineUUIDs() {
|
||||
if !isLogin && hiddenMap[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apiClient "github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
|
||||
"github.com/komari-monitor/komari/compat/nezha/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// [Deprecated] Use StartNezhaCompat instead.
|
||||
func StartNezhaCompatServer(addr string) error {
|
||||
boot := uint64(time.Now().Unix())
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &nezhaCompatServer{bootTime: boot}
|
||||
|
||||
// Interceptors for metadata auth presence (lightweight; validation is inside handlers too)
|
||||
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
return handler(srvIface, ss)
|
||||
}
|
||||
// Enable gRPC keepalive to tolerate long-idle streams
|
||||
gs := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(unary),
|
||||
grpc.StreamInterceptor(stream),
|
||||
// Keepalive: allow client pings and keep connections stable without app traffic
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{
|
||||
Time: 2 * time.Minute,
|
||||
Timeout: 20 * time.Second,
|
||||
}),
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
|
||||
MinTime: 20 * time.Second,
|
||||
PermitWithoutStream: true,
|
||||
}),
|
||||
)
|
||||
proto.RegisterNezhaServiceServer(gs, srv)
|
||||
log.Printf("Nezha compat gRPC listening on %s", addr)
|
||||
return gs.Serve(lis)
|
||||
}
|
||||
|
||||
// ---- Manual start/stop support ----
|
||||
var (
|
||||
nezhaSrv *grpc.Server
|
||||
nezhaLis net.Listener
|
||||
nezhaOnceM sync.Mutex
|
||||
)
|
||||
|
||||
// StartNezhaCompat starts the Nezha compatible gRPC server asynchronously.
|
||||
// Returns error if already started.
|
||||
func StartNezhaCompat(addr string) error {
|
||||
nezhaOnceM.Lock()
|
||||
defer nezhaOnceM.Unlock()
|
||||
if nezhaSrv != nil {
|
||||
return errors.New("nezha compat server already started")
|
||||
}
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
boot := uint64(time.Now().Unix())
|
||||
sImpl := &nezhaCompatServer{bootTime: boot}
|
||||
|
||||
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
return handler(srvIface, ss)
|
||||
}
|
||||
gs := grpc.NewServer(
|
||||
grpc.UnaryInterceptor(unary),
|
||||
grpc.StreamInterceptor(stream),
|
||||
grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute, Timeout: 20 * time.Second}),
|
||||
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
|
||||
)
|
||||
proto.RegisterNezhaServiceServer(gs, sImpl)
|
||||
nezhaSrv = gs
|
||||
nezhaLis = lis
|
||||
go func() {
|
||||
if err := gs.Serve(lis); err != nil {
|
||||
log.Printf("Nezha compat gRPC server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("Nezha compat gRPC started on %s", addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopNezhaCompat stops the server if running.
|
||||
func StopNezhaCompat() error {
|
||||
nezhaOnceM.Lock()
|
||||
defer nezhaOnceM.Unlock()
|
||||
if nezhaSrv == nil {
|
||||
return errors.New("nezha compat server not running")
|
||||
}
|
||||
// 强制立即断开所有连接与流,不等待在途 RPC 完成。
|
||||
nezhaSrv.Stop()
|
||||
// Listener close (Serve already returns after GracefulStop, but close to be explicit)
|
||||
if nezhaLis != nil {
|
||||
_ = nezhaLis.Close()
|
||||
}
|
||||
nezhaSrv = nil
|
||||
nezhaLis = nil
|
||||
log.Printf("Nezha compat gRPC stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
type nezhaCompatServer struct {
|
||||
proto.UnimplementedNezhaServiceServer
|
||||
bootTime uint64
|
||||
}
|
||||
|
||||
// authentication helpers
|
||||
func getAuth(ctx context.Context) (uuid string, secret string, err error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return "", "", errors.New("missing metadata")
|
||||
}
|
||||
getFirst := func(key string) string {
|
||||
vals := md.Get(key)
|
||||
if len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
uuid = getFirst("client_uuid")
|
||||
secret = getFirst("client_secret")
|
||||
if uuid == "" || secret == "" {
|
||||
return "", "", errors.New("unauthorized: missing client_uuid/client_secret")
|
||||
}
|
||||
return uuid, secret, nil
|
||||
}
|
||||
|
||||
// ReportSystemInfo: upsert static host info
|
||||
func (s *nezhaCompatServer) ReportSystemInfo(ctx context.Context, host *proto.Host) (*proto.Receipt, error) {
|
||||
uuid, secret, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertClientFromHost(uuid, secret, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.Receipt{Proced: true}, nil
|
||||
}
|
||||
|
||||
// ReportSystemInfo2: same as above but returns dashboard boot time
|
||||
func (s *nezhaCompatServer) ReportSystemInfo2(ctx context.Context, host *proto.Host) (*proto.Uint64Receipt, error) {
|
||||
uuid, secret, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertClientFromHost(uuid, secret, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.Uint64Receipt{Data: s.bootTime}, nil
|
||||
}
|
||||
|
||||
// ReportSystemState: ingest time-series records
|
||||
func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSystemStateServer) error {
|
||||
ctx := stream.Context()
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// presence start
|
||||
connID := time.Now().UnixNano()
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
go notifier.OnlineNotification(uuid, connID)
|
||||
defer func() {
|
||||
ws.SetPresence(uuid, connID, false)
|
||||
notifier.OfflineNotification(uuid, connID)
|
||||
}()
|
||||
for {
|
||||
st, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// refresh presence TTL on every frame
|
||||
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
if err := ingestState(uuid, st); err != nil {
|
||||
// still ack to avoid client stuck; log error
|
||||
log.Printf("Nezha ingest state error: %v", err)
|
||||
}
|
||||
if err := stream.Send(&proto.Receipt{Proced: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequestTask: do not dispatch tasks, just drain results to avoid Unimplemented
|
||||
func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskServer) error {
|
||||
ctx := stream.Context()
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
connID := time.Now().UnixNano()
|
||||
ws.SetPresence(uuid, connID, true)
|
||||
defer ws.SetPresence(uuid, connID, false)
|
||||
// receive results in background
|
||||
recvErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
_, rerr := stream.Recv()
|
||||
if rerr == io.EOF {
|
||||
recvErr <- nil
|
||||
return
|
||||
}
|
||||
if rerr != nil {
|
||||
recvErr <- rerr
|
||||
return
|
||||
}
|
||||
// refresh presence TTL when result received
|
||||
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
|
||||
}
|
||||
}()
|
||||
// send heartbeat tasks periodically
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-recvErr:
|
||||
return err
|
||||
case <-ticker.C:
|
||||
if err := stream.Send(&proto.Task{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unimplemented methods intentionally left as default (IOStream, ReportGeoIP)
|
||||
|
||||
// upsertClientFromHost maps Host into models.Client and upserts by UUID
|
||||
func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
now := models.FromTime(time.Now())
|
||||
// token guard: if existing record has different token, reject
|
||||
var exist models.Client
|
||||
if err := db.Where("uuid = ?", uuid).First(&exist).Error; err == nil {
|
||||
if exist.Token != "" && exist.Token != secret {
|
||||
return errors.New("unauthorized: token mismatch")
|
||||
}
|
||||
}
|
||||
cpuName := ""
|
||||
if len(h.Cpu) > 0 {
|
||||
cpuName = h.Cpu[0]
|
||||
}
|
||||
gpuName := strings.Join(h.Gpu, "; ")
|
||||
osName := h.Platform
|
||||
if h.PlatformVersion != "" {
|
||||
osName = h.Platform + " " + h.PlatformVersion
|
||||
}
|
||||
// clamp uint64 to int64 safely
|
||||
clamp := func(v uint64) int64 {
|
||||
if v > uint64(math.MaxInt64) {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(v)
|
||||
}
|
||||
c := models.Client{
|
||||
UUID: uuid,
|
||||
Token: secret,
|
||||
Name: "nezha_" + uuid[0:8],
|
||||
CpuName: cpuName,
|
||||
Arch: h.Arch,
|
||||
CpuCores: len(h.Cpu),
|
||||
OS: osName,
|
||||
KernelVersion: h.PlatformVersion,
|
||||
Virtualization: h.Virtualization,
|
||||
GpuName: gpuName,
|
||||
MemTotal: clamp(h.MemTotal),
|
||||
SwapTotal: clamp(h.SwapTotal),
|
||||
DiskTotal: clamp(h.DiskTotal),
|
||||
Version: h.Version,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
// Upsert by UUID; don't override existing Token if already set
|
||||
updates := map[string]interface{}{
|
||||
"cpu_name": c.CpuName,
|
||||
"arch": c.Arch,
|
||||
"cpu_cores": c.CpuCores,
|
||||
"os": c.OS,
|
||||
"kernel_version": c.KernelVersion,
|
||||
"virtualization": c.Virtualization,
|
||||
"gpu_name": c.GpuName,
|
||||
"mem_total": c.MemTotal,
|
||||
"swap_total": c.SwapTotal,
|
||||
"disk_total": c.DiskTotal,
|
||||
"version": c.Version,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(&c).Error
|
||||
}
|
||||
|
||||
// ingestState maps Nezha State into common.Report then saves a Record
|
||||
func ingestState(uuid string, st *proto.State) error {
|
||||
// we may need totals from client
|
||||
db := dbcore.GetDBInstance()
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
// If missing, create with minimal defaults to avoid failing ingestion
|
||||
client = models.Client{UUID: uuid, Token: "", Name: "nezha_" + uuid[0:8]}
|
||||
auditlog.EventLog("info", "auto created client "+client.Name)
|
||||
_ = db.Create(&client).Error
|
||||
}
|
||||
rep := common.Report{
|
||||
CPU: common.CPUReport{Usage: st.Cpu},
|
||||
Ram: common.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
|
||||
Swap: common.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
|
||||
Load: common.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
|
||||
Disk: common.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
|
||||
Network: common.NetworkReport{
|
||||
Up: int64(st.NetOutSpeed),
|
||||
Down: int64(st.NetInSpeed),
|
||||
TotalUp: int64(st.NetOutTransfer),
|
||||
TotalDown: int64(st.NetInTransfer),
|
||||
},
|
||||
Uptime: int64(st.Uptime),
|
||||
Process: int(st.ProcessCount),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
// 更新实时缓存供前端使用
|
||||
ws.SetLatestReport(uuid, &rep)
|
||||
// 写入内存缓存,入库交由定时聚合任务处理
|
||||
return apiClient.SaveClientReport(uuid, rep)
|
||||
}
|
||||
|
||||
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
|
||||
func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*proto.GeoIP, error) {
|
||||
uuid, _, err := getAuth(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
var iso string
|
||||
if in != nil && in.Ip != nil {
|
||||
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
|
||||
updates["ipv4"] = v4
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v4); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
|
||||
updates["ipv6"] = v6
|
||||
if iso == "" { // 优先使用 v4 的国家码
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v6); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if iso != "" {
|
||||
// UI 使用旗帜 emoji
|
||||
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
}
|
||||
// 回写 GeoIP(包含国家码与面板启动时间)
|
||||
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
|
||||
return resp, nil
|
||||
}
|
||||
+46
-17
@@ -21,13 +21,15 @@ import (
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/jsonRpc"
|
||||
public_api "github.com/komari-monitor/komari/api/public"
|
||||
"github.com/komari-monitor/komari/api/record"
|
||||
"github.com/komari-monitor/komari/api/task"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
@@ -37,6 +39,7 @@ import (
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
@@ -76,7 +79,7 @@ func RunServer() {
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.Get()
|
||||
conf, err := config.GetManyAs[config.Legacy]()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -85,9 +88,22 @@ func RunServer() {
|
||||
go messageSender.Initialize()
|
||||
// oidcInit
|
||||
go oauth.Initialize()
|
||||
|
||||
if conf.NezhaCompatEnabled {
|
||||
go func() {
|
||||
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
|
||||
log.Printf("Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if event.New.OAuthProvider != event.Old.OAuthProvider {
|
||||
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
|
||||
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
|
||||
if t == "" || t == "none" {
|
||||
t = "github"
|
||||
}
|
||||
oidcProvider, err := database.GetOidcConfigByName(t)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
} else {
|
||||
@@ -98,8 +114,20 @@ func RunServer() {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
messageSender.Initialize()
|
||||
|
||||
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
|
||||
if t {
|
||||
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
|
||||
if err := StartNezhaCompat(l); err != nil {
|
||||
log.Printf("start Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
|
||||
}
|
||||
} else {
|
||||
if err := StopNezhaCompat(); err != nil {
|
||||
log.Printf("stop Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
@@ -111,19 +139,25 @@ func RunServer() {
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
// 动态 CORS 中间件
|
||||
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
|
||||
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
|
||||
DynamicCorsEnabled = t
|
||||
}
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
@@ -170,7 +204,7 @@ func RunServer() {
|
||||
r.GET("/api/task/ping", task.GetPublicPingTasks)
|
||||
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
|
||||
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
@@ -311,11 +345,6 @@ func RunServer() {
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
// #region 静态文件服务
|
||||
public.UpdateIndex(conf)
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
public.UpdateIndex(event.New)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
@@ -371,9 +400,9 @@ func DoScheduledWork() {
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
cfg, _ := config.Get()
|
||||
go notifier.CheckExpireScheduledWork()
|
||||
for {
|
||||
cfg, _ := config.GetManyAs[config.Legacy]()
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
|
||||
@@ -69,6 +69,7 @@ type Report struct {
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
@@ -83,6 +84,23 @@ type CPUReport struct {
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// GPUDetailReport 详细GPU信息报告
|
||||
type GPUDetailReport struct {
|
||||
Count int `json:"count"` // GPU数量
|
||||
AverageUsage float64 `json:"average_usage"` // 平均使用率
|
||||
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
|
||||
}
|
||||
|
||||
// GPUDeviceInfo 单个GPU设备信息
|
||||
type GPUDeviceInfo struct {
|
||||
Name string `json:"name"` // GPU型号名称
|
||||
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
|
||||
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
|
||||
Utilization float64 `json:"utilization"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// 保持向后兼容的旧GPUReport结构
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
syntax = "proto3";
|
||||
package proto;
|
||||
option go_package = "github.com/komari-monitor/komari/compat/nezha/proto";
|
||||
|
||||
service NezhaService {
|
||||
rpc ReportSystemState(stream State) returns (stream Receipt) {}
|
||||
rpc ReportSystemInfo(Host) returns (Receipt) {}
|
||||
rpc RequestTask(stream TaskResult) returns (stream Task) {}
|
||||
rpc IOStream(stream IOStreamData) returns (stream IOStreamData) {}
|
||||
rpc ReportGeoIP(GeoIP) returns (GeoIP) {}
|
||||
rpc ReportSystemInfo2(Host) returns (Uint64Receipt) {}
|
||||
}
|
||||
|
||||
message Host {
|
||||
string platform = 1;
|
||||
string platform_version = 2;
|
||||
repeated string cpu = 3;
|
||||
uint64 mem_total = 4;
|
||||
uint64 disk_total = 5;
|
||||
uint64 swap_total = 6;
|
||||
string arch = 7;
|
||||
string virtualization = 8;
|
||||
uint64 boot_time = 9;
|
||||
string version = 10;
|
||||
repeated string gpu = 11;
|
||||
}
|
||||
|
||||
message State {
|
||||
double cpu = 1;
|
||||
uint64 mem_used = 2;
|
||||
uint64 swap_used = 3;
|
||||
uint64 disk_used = 4;
|
||||
uint64 net_in_transfer = 5;
|
||||
uint64 net_out_transfer = 6;
|
||||
uint64 net_in_speed = 7;
|
||||
uint64 net_out_speed = 8;
|
||||
uint64 uptime = 9;
|
||||
double load1 = 10;
|
||||
double load5 = 11;
|
||||
double load15 = 12;
|
||||
uint64 tcp_conn_count = 13;
|
||||
uint64 udp_conn_count = 14;
|
||||
uint64 process_count = 15;
|
||||
repeated State_SensorTemperature temperatures = 16;
|
||||
repeated double gpu = 17;
|
||||
}
|
||||
|
||||
message State_SensorTemperature {
|
||||
string name = 1;
|
||||
double temperature = 2;
|
||||
}
|
||||
|
||||
message Task {
|
||||
uint64 id = 1;
|
||||
uint64 type = 2;
|
||||
string data = 3;
|
||||
}
|
||||
|
||||
message TaskResult {
|
||||
uint64 id = 1;
|
||||
uint64 type = 2;
|
||||
float delay = 3;
|
||||
string data = 4;
|
||||
bool successful = 5;
|
||||
}
|
||||
|
||||
message Receipt { bool proced = 1; }
|
||||
|
||||
message Uint64Receipt { uint64 data = 1; }
|
||||
|
||||
message IOStreamData { bytes data = 1; }
|
||||
|
||||
message GeoIP {
|
||||
bool use6 = 1;
|
||||
IP ip = 2;
|
||||
string country_code = 3;
|
||||
uint64 dashboard_boot_time = 4;
|
||||
}
|
||||
|
||||
message IP {
|
||||
string ipv4 = 1;
|
||||
string ipv6 = 2;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,374 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
// NezhaServiceClient is the client API for NezhaService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type NezhaServiceClient interface {
|
||||
ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error)
|
||||
ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error)
|
||||
RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error)
|
||||
IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error)
|
||||
ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error)
|
||||
ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error)
|
||||
}
|
||||
|
||||
type nezhaServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewNezhaServiceClient(cc grpc.ClientConnInterface) NezhaServiceClient {
|
||||
return &nezhaServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[0], "/proto.NezhaService/ReportSystemState", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceReportSystemStateClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_ReportSystemStateClient interface {
|
||||
Send(*State) error
|
||||
Recv() (*Receipt, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceReportSystemStateClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateClient) Send(m *State) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateClient) Recv() (*Receipt, error) {
|
||||
m := new(Receipt)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error) {
|
||||
out := new(Receipt)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[1], "/proto.NezhaService/RequestTask", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceRequestTaskClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_RequestTaskClient interface {
|
||||
Send(*TaskResult) error
|
||||
Recv() (*Task, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceRequestTaskClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskClient) Send(m *TaskResult) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskClient) Recv() (*Task, error) {
|
||||
m := new(Task)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[2], "/proto.NezhaService/IOStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &nezhaServiceIOStreamClient{stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type NezhaService_IOStreamClient interface {
|
||||
Send(*IOStreamData) error
|
||||
Recv() (*IOStreamData, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type nezhaServiceIOStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamClient) Send(m *IOStreamData) error {
|
||||
return x.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamClient) Recv() (*IOStreamData, error) {
|
||||
m := new(IOStreamData)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error) {
|
||||
out := new(GeoIP)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportGeoIP", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *nezhaServiceClient) ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error) {
|
||||
out := new(Uint64Receipt)
|
||||
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo2", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NezhaServiceServer is the server API for NezhaService service.
|
||||
// All implementations must embed UnimplementedNezhaServiceServer
|
||||
// for forward compatibility
|
||||
type NezhaServiceServer interface {
|
||||
ReportSystemState(NezhaService_ReportSystemStateServer) error
|
||||
ReportSystemInfo(context.Context, *Host) (*Receipt, error)
|
||||
RequestTask(NezhaService_RequestTaskServer) error
|
||||
IOStream(NezhaService_IOStreamServer) error
|
||||
ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error)
|
||||
ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error)
|
||||
mustEmbedUnimplementedNezhaServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedNezhaServiceServer must be embedded to have forward compatible implementations.
|
||||
type UnimplementedNezhaServiceServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemState(NezhaService_ReportSystemStateServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ReportSystemState not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemInfo(context.Context, *Host) (*Receipt, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) RequestTask(NezhaService_RequestTaskServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method RequestTask not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) IOStream(NezhaService_IOStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method IOStream not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportGeoIP not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo2 not implemented")
|
||||
}
|
||||
func (UnimplementedNezhaServiceServer) mustEmbedUnimplementedNezhaServiceServer() {}
|
||||
|
||||
// UnsafeNezhaServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to NezhaServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeNezhaServiceServer interface {
|
||||
mustEmbedUnimplementedNezhaServiceServer()
|
||||
}
|
||||
|
||||
func RegisterNezhaServiceServer(s *grpc.Server, srv NezhaServiceServer) {
|
||||
s.RegisterService(&_NezhaService_serviceDesc, srv)
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemState_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).ReportSystemState(&nezhaServiceReportSystemStateServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_ReportSystemStateServer interface {
|
||||
Send(*Receipt) error
|
||||
Recv() (*State, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceReportSystemStateServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateServer) Send(m *Receipt) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceReportSystemStateServer) Recv() (*State, error) {
|
||||
m := new(State)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Host)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportSystemInfo",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, req.(*Host))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NezhaService_RequestTask_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).RequestTask(&nezhaServiceRequestTaskServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_RequestTaskServer interface {
|
||||
Send(*Task) error
|
||||
Recv() (*TaskResult, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceRequestTaskServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskServer) Send(m *Task) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceRequestTaskServer) Recv() (*TaskResult, error) {
|
||||
m := new(TaskResult)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_IOStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(NezhaServiceServer).IOStream(&nezhaServiceIOStreamServer{stream})
|
||||
}
|
||||
|
||||
type NezhaService_IOStreamServer interface {
|
||||
Send(*IOStreamData) error
|
||||
Recv() (*IOStreamData, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type nezhaServiceIOStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamServer) Send(m *IOStreamData) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (x *nezhaServiceIOStreamServer) Recv() (*IOStreamData, error) {
|
||||
m := new(IOStreamData)
|
||||
if err := x.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func _NezhaService_ReportGeoIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GeoIP)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportGeoIP(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportGeoIP",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportGeoIP(ctx, req.(*GeoIP))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _NezhaService_ReportSystemInfo2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Host)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/proto.NezhaService/ReportSystemInfo2",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, req.(*Host))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
var _NezhaService_serviceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "proto.NezhaService",
|
||||
HandlerType: (*NezhaServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ReportSystemInfo",
|
||||
Handler: _NezhaService_ReportSystemInfo_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportGeoIP",
|
||||
Handler: _NezhaService_ReportGeoIP_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ReportSystemInfo2",
|
||||
Handler: _NezhaService_ReportSystemInfo2_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "ReportSystemState",
|
||||
Handler: _NezhaService_ReportSystemState_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "RequestTask",
|
||||
Handler: _NezhaService_RequestTask_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "IOStream",
|
||||
Handler: _NezhaService_IOStream_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "compat/nezha/nezha.proto",
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
Key string `gorm:"primaryKey;column:key;type:text"`
|
||||
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
|
||||
}
|
||||
|
||||
func (ConfigItem) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
SetDb = func(gdb *gorm.DB) {
|
||||
db = gdb
|
||||
migrateInPlace()
|
||||
}
|
||||
)
|
||||
|
||||
func migrateInPlace() {
|
||||
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
|
||||
slog.Info("[>1.1.4] Moving legacy config data...")
|
||||
|
||||
var oldData Legacy
|
||||
if err := db.Order("id desc").First(&oldData).Error; err != nil {
|
||||
db.Migrator().DropTable("configs")
|
||||
} else {
|
||||
var newRows []ConfigItem
|
||||
val := reflect.ValueOf(oldData)
|
||||
typ := reflect.TypeOf(oldData)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
key := strings.Split(tag, ",")[0]
|
||||
|
||||
// 过滤 id 和无用字段
|
||||
if key == "" || key == "-" || key == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
valInterface := val.Field(i).Interface()
|
||||
jsonBytes, _ := json.Marshal(valInterface)
|
||||
|
||||
newRows = append(newRows, ConfigItem{
|
||||
Key: key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newRows) > 0 {
|
||||
return tx.Create(&newRows).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.AutoMigrate(&ConfigItem{})
|
||||
}
|
||||
|
||||
// Get 获取原始值 (反序列化为 interface{})
|
||||
func Get(key string, defaul ...any) (any, error) {
|
||||
var item ConfigItem
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v := defaul[0]
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetAs 获取并转换为指定类型 (泛型)
|
||||
func GetAs[T any](key string, defaul ...any) (T, error) {
|
||||
var t T
|
||||
var item ConfigItem
|
||||
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v, ok := defaul[0].(T)
|
||||
if !ok {
|
||||
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
|
||||
}
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(item.Value), &t)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// key[defaults]
|
||||
func GetMany(keys map[string]any) (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
keyList := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
keyList = append(keyList, k)
|
||||
}
|
||||
if len(keyList) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
foundKeys[item.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in defaults for missing keys
|
||||
for k, def := range keys {
|
||||
if _, found := foundKeys[k]; !found {
|
||||
if def != nil {
|
||||
result[k] = def
|
||||
_ = Set(k, def) // Ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
|
||||
func GetManyAs[T any]() (*T, error) {
|
||||
var t T
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
tagToFieldMap := make(map[string]int) // Key -> FieldIndex
|
||||
keys := make([]string, 0)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
if tag != "" && tag != "-" {
|
||||
keys = append(keys, tag)
|
||||
tagToFieldMap[tag] = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
var items []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
fieldIndex, ok := tagToFieldMap[item.Key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldVal := val.Field(fieldIndex)
|
||||
if !fieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
|
||||
if err := json.Unmarshal([]byte(item.Value), target); err == nil {
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func GetAll() (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Set 设置单个配置
|
||||
func Set(key string, value any) error {
|
||||
oldVal := map[string]any{}
|
||||
{
|
||||
var oldItem ConfigItem
|
||||
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
|
||||
oldVal[key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := ConfigItem{
|
||||
Key: key,
|
||||
Value: string(bytes),
|
||||
}
|
||||
|
||||
err = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newVal := map[string]any{key: value}
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMany 将结构体保存为多个配置项
|
||||
func SetManyAs[T any](config T) error {
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
var items []ConfigItem
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
tag := fieldType.Tag.Get("json")
|
||||
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := val.Field(i).Interface()
|
||||
|
||||
bytes, err := json.Marshal(fieldValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
|
||||
}
|
||||
|
||||
items = append(items, ConfigItem{
|
||||
Key: tag,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetMany(cst map[string]any) error {
|
||||
var items []ConfigItem
|
||||
for k, v := range cst {
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key %s failed: %w", k, err)
|
||||
}
|
||||
items = append(items, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigEvent struct {
|
||||
Old map[string]any // Old models.Config
|
||||
New map[string]any // New models.Config
|
||||
}
|
||||
|
||||
func (e ConfigEvent) IsChanged(key string) bool {
|
||||
oldVal, oldOk := e.Old[key]
|
||||
newVal, newOk := e.New[key]
|
||||
if !oldOk && !newOk {
|
||||
return false
|
||||
}
|
||||
if oldOk != newOk {
|
||||
return true
|
||||
}
|
||||
return !reflect.DeepEqual(oldVal, newVal)
|
||||
}
|
||||
|
||||
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
|
||||
changed := e.IsChanged(key)
|
||||
var zero T
|
||||
|
||||
val, ok := e.New[key]
|
||||
if !ok {
|
||||
val, ok = e.Old[key]
|
||||
if !ok {
|
||||
return changed, zero
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// Fast path: direct assertion.
|
||||
if cast, ok := val.(T); ok {
|
||||
return changed, cast
|
||||
}
|
||||
|
||||
// Try reflection-based conversion (covers numeric conversions, etc.).
|
||||
targetType := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v := reflect.ValueOf(val)
|
||||
if v.IsValid() {
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
return changed, v.Interface().(T)
|
||||
}
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
converted := v.Convert(targetType)
|
||||
return changed, converted.Interface().(T)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
|
||||
if b, err := json.Marshal(val); err == nil {
|
||||
var out T
|
||||
if err := json.Unmarshal(b, &out); err == nil {
|
||||
return changed, out
|
||||
}
|
||||
}
|
||||
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal map[string]any) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
for _, sub := range subscribers {
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Legacy struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
|
||||
// OAuth 配置
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
|
||||
// Record
|
||||
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
|
||||
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
AllowCorsKey = "allow_cors"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
NezhaCompatEnabledKey = "nezha_compat_enabled"
|
||||
NezhaCompatListenKey = "nezha_compat_listen"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
NotificationEnabledKey = "notification_enabled"
|
||||
NotificationMethodKey = "notification_method"
|
||||
NotificationTemplateKey = "notification_template"
|
||||
ExpireNotificationEnabledKey = "expire_notification_enabled"
|
||||
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
RecordEnabledKey = "record_enabled"
|
||||
RecordPreserveTimeKey = "record_preserve_time"
|
||||
PingRecordPreserveTimeKey = "ping_record_preserve_time"
|
||||
UpdatedAtKey = "updated_at"
|
||||
)
|
||||
|
||||
func (Legacy) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
// Decrepted
|
||||
/*
|
||||
func Update(cst map[string]interface{}) error {
|
||||
oldConfig, _ := GetManyAs[Legacy]()
|
||||
// Proceed with update
|
||||
cst["updated_at"] = time.Now().Unix()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
err := Db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
//publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
@@ -38,16 +39,23 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
if cfg.LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil {
|
||||
loc = ipinfo.Name
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
if LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
messageSender.SendTextMessage(fmt.Sprintf("From: %s,(%s)\nMethod: %s\nUser Agent: %s\n\n%s", ip, loc, login_method, userAgent, time.Now().Format(time.RFC3339)), "New Login on Komari")
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -144,11 +144,37 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
}
|
||||
|
||||
// 保存GPU详细记录到独立表
|
||||
currentTime := time.Now()
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
for idx, gpu := range report.GPU.DetailedInfo {
|
||||
gpuRecord := models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(currentTime),
|
||||
DeviceIndex: idx,
|
||||
DeviceName: gpu.Name,
|
||||
MemTotal: gpu.MemoryTotal,
|
||||
MemUsed: gpu.MemoryUsed,
|
||||
Utilization: float32(gpu.Utilization),
|
||||
Temperature: gpu.Temperature,
|
||||
}
|
||||
if err := db.Create(&gpuRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save GPU record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均GPU使用率,用于向后兼容
|
||||
averageGPUUsage := float32(0)
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
averageGPUUsage = float32(report.GPU.AverageUsage)
|
||||
}
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(time.Now()),
|
||||
Time: models.FromTime(currentTime),
|
||||
Cpu: float32(report.CPU.Usage),
|
||||
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
|
||||
Gpu: averageGPUUsage, // 使用平均GPU使用率
|
||||
Ram: report.Ram.Used,
|
||||
RamTotal: report.Ram.Total,
|
||||
Swap: report.Swap.Used,
|
||||
@@ -164,6 +190,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
//Uptime: report.Uptime,
|
||||
}
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func init() {
|
||||
mu = sync.Mutex{}
|
||||
}
|
||||
|
||||
func Get() (models.Config, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.Config
|
||||
if err := db.First(&config).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
config = models.Config{
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ip-api",
|
||||
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)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Save(cst models.Config) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = models.FromTime(time.Now())
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("*").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newConfig, _ := Get()
|
||||
publishEvent(oldConfig, newConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a configuration change event
|
||||
type ConfigEvent struct {
|
||||
Old models.Config
|
||||
New models.Config
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal models.Config) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
for _, sub := range subscribers {
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
+226
-23
@@ -1,21 +1,171 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/driver/mysql"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
|
||||
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
|
||||
// 规范化排除路径为绝对路径
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
|
||||
out, err := os.Create(dstZip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDir)
|
||||
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 排除 backup.zip 本身
|
||||
if _, ok := normExclude[path]; ok {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 计算 zip 内相对路径
|
||||
rel, err := filepath.Rel(absSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 根目录跳过
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// 替换为正斜杠
|
||||
zipName := filepath.ToSlash(rel)
|
||||
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(zipName + "/")
|
||||
return err
|
||||
}
|
||||
// 普通文件
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(zipName)
|
||||
if err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(w, fh); err != nil {
|
||||
fh.Close()
|
||||
return err
|
||||
}
|
||||
fh.Close()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
|
||||
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normExclude := make(map[string]struct{}, len(exclude))
|
||||
for p := range exclude {
|
||||
abs, _ := filepath.Abs(p)
|
||||
normExclude[abs] = struct{}{}
|
||||
}
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := filepath.Join(absDir, e.Name())
|
||||
if _, ok := normExclude[full]; ok {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
|
||||
func unzipToDir(zipPath, dstDir string) error {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
absDst, _ := filepath.Abs(dstDir)
|
||||
|
||||
for _, f := range zr.File {
|
||||
// 构造目标路径并做路径遍历保护
|
||||
cleanName := filepath.Clean(f.Name)
|
||||
targetPath := filepath.Join(absDst, cleanName)
|
||||
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
|
||||
return fmt.Errorf("illegal file path in zip: %s", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
@@ -210,16 +360,55 @@ var (
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
once.Do(func() {
|
||||
|
||||
var err error
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
if utils.VersionHash == "unknown" {
|
||||
logConfig = &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logutil.NewGormLogger(),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
@@ -231,30 +420,37 @@ func GetDBInstance() *gorm.DB {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
|
||||
case "mysql":
|
||||
// MySQL 连接
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
flags.DatabaseUser,
|
||||
flags.DatabasePass,
|
||||
flags.DatabaseHost,
|
||||
flags.DatabasePort,
|
||||
flags.DatabaseName)
|
||||
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
instance.Exec("PRAGMA wal = ON;")
|
||||
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
}
|
||||
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
instance.Exec("VACUUM;")
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
// case "mysql":
|
||||
// // MySQL 连接
|
||||
// dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
// flags.DatabaseUser,
|
||||
// flags.DatabasePass,
|
||||
// flags.DatabaseHost,
|
||||
// flags.DatabasePort,
|
||||
// flags.DatabaseName)
|
||||
// instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
// }
|
||||
// log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.Config{},
|
||||
&models.GPURecord{},
|
||||
// &models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
@@ -274,6 +470,12 @@ func GetDBInstance() *gorm.DB {
|
||||
if err != nil {
|
||||
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.Table("gpu_records_long_term").AutoMigrate(
|
||||
&models.GPURecord{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
|
||||
}
|
||||
err = instance.AutoMigrate(
|
||||
&models.Session{},
|
||||
)
|
||||
@@ -289,5 +491,6 @@ func GetDBInstance() *gorm.DB {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package models
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
|
||||
// OAuth 配置
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
|
||||
@@ -22,6 +28,7 @@ type Config struct {
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package messageevent
|
||||
|
||||
const (
|
||||
Offline = "Offline"
|
||||
Online = "Online"
|
||||
Expire = "Expire"
|
||||
Renew = "Renew"
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
)
|
||||
@@ -1,6 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type MessageSenderProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event string `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
@@ -90,6 +90,19 @@ type Record struct {
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
|
||||
}
|
||||
|
||||
// GPURecord logs individual GPU metrics over time
|
||||
type GPURecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time LocalTime `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
|
||||
@@ -15,18 +15,18 @@ type Theme struct {
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name string `json:"name"`
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Name any `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help string `json:"help"`
|
||||
Help any `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
|
||||
@@ -18,14 +18,61 @@ func RecordOne(rec models.Record) error {
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func RecordGPU(rec models.GPURecord) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&rec).Error
|
||||
}
|
||||
|
||||
func DeleteAll() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("DELETE FROM records").Error
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取GPU记录数据
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.GPURecord
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
var recentRecords []models.GPURecord
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
if recentStart.Before(fourHoursAgo) {
|
||||
recentStart = fourHoursAgo
|
||||
}
|
||||
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
|
||||
Order("time ASC, device_index ASC").Find(&recentRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var longTermRecords []models.GPURecord
|
||||
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
|
||||
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
|
||||
if err != nil {
|
||||
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
|
||||
return recentRecords, nil
|
||||
}
|
||||
|
||||
// 合并结果 - 不再需要类型转换
|
||||
records = append(records, recentRecords...)
|
||||
records = append(records, longTermRecords...)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
|
||||
@@ -35,6 +82,8 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
|
||||
func DeleteRecordBefore(before time.Time) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
|
||||
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
db.Where("time < ?", before).Delete(&models.GPURecord{})
|
||||
return db.Where("time < ?", before).Delete(&models.Record{}).Error
|
||||
}
|
||||
|
||||
@@ -117,10 +166,17 @@ func CompactRecord() error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = migrateGPURecords(db)
|
||||
if err != nil {
|
||||
log.Printf("Error migrating GPU records: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.DatabaseType == "sqlite" {
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
log.Printf("Error vacuuming database: %v", err)
|
||||
}
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
//log.Printf("Record compaction completed")
|
||||
return nil
|
||||
@@ -159,6 +215,7 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
}
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
@@ -185,6 +242,7 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
data.Process = append(data.Process, record.Process)
|
||||
data.Connections = append(data.Connections, record.Connections)
|
||||
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
|
||||
//data.Uptime = append(data.Uptime, record.Uptime)
|
||||
}
|
||||
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
@@ -279,6 +337,7 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
Process: getInt32Percentile(data.Process, high_percentile),
|
||||
Connections: getInt32Percentile(data.Connections, high_percentile),
|
||||
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
|
||||
//Uptime: getIntPercentile(data.Uptime, high_percentile),
|
||||
}
|
||||
|
||||
// 如果记录已存在则更新,否则创建新记录
|
||||
@@ -301,3 +360,147 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// migrateGPURecords 压缩GPU记录数据
|
||||
func migrateGPURecords(db *gorm.DB) error {
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
|
||||
// 查询超过4小时的GPU记录
|
||||
var gpuRecords []models.GPURecord
|
||||
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(gpuRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按Client + DeviceIndex + 15分钟时间窗口分组
|
||||
type gpuGroupKey struct {
|
||||
Client string
|
||||
DeviceIndex int
|
||||
TimeSlot time.Time
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type gpuGroupData struct {
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float32
|
||||
Temperature []int
|
||||
}
|
||||
|
||||
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
key := gpuGroupKey{
|
||||
Client: record.Client,
|
||||
DeviceIndex: record.DeviceIndex,
|
||||
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
|
||||
DeviceName: record.DeviceName,
|
||||
}
|
||||
|
||||
if _, ok := groupedGPUs[key]; !ok {
|
||||
groupedGPUs[key] = &gpuGroupData{}
|
||||
}
|
||||
|
||||
data := groupedGPUs[key]
|
||||
data.MemTotal = append(data.MemTotal, record.MemTotal)
|
||||
data.MemUsed = append(data.MemUsed, record.MemUsed)
|
||||
data.Utilization = append(data.Utilization, record.Utilization)
|
||||
data.Temperature = append(data.Temperature, record.Temperature)
|
||||
}
|
||||
|
||||
// 百分位数计算函数 (复用传统Record压缩逻辑)
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sortedValues := make([]float64, len(values))
|
||||
copy(sortedValues, values)
|
||||
sort.Float64s(sortedValues)
|
||||
index := float64(len(sortedValues)-1) * percentile
|
||||
lowerIndex := int(index)
|
||||
if lowerIndex >= len(sortedValues)-1 {
|
||||
return sortedValues[len(sortedValues)-1]
|
||||
}
|
||||
frac := index - float64(lowerIndex)
|
||||
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
|
||||
}
|
||||
|
||||
getIntPercentile := func(values []int64, percentile float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return int64(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 温度数据转换辅助函数
|
||||
convertIntToInt64 := func(values []int) []int64 {
|
||||
result := make([]int64, len(values))
|
||||
for i, v := range values {
|
||||
result[i] = int64(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getFloat32Percentile := func(values []float32, percentile float64) float32 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
floats := make([]float64, len(values))
|
||||
for i, v := range values {
|
||||
floats[i] = float64(v)
|
||||
}
|
||||
return float32(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
// 保持与传统Record压缩的一致性
|
||||
high_percentile := 0.7
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, data := range groupedGPUs {
|
||||
// 检查是否已存在记录
|
||||
var existingCount int64
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
compressedGPU := models.GPURecord{
|
||||
Client: key.Client,
|
||||
DeviceIndex: key.DeviceIndex,
|
||||
Time: models.FromTime(key.TimeSlot),
|
||||
DeviceName: key.DeviceName,
|
||||
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
|
||||
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
|
||||
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
|
||||
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
|
||||
}
|
||||
|
||||
if existingCount > 0 {
|
||||
// 更新已存在记录
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 创建新记录
|
||||
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除已压缩的原始GPU数据
|
||||
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
+67
-2
@@ -8,16 +8,81 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (any, error) {
|
||||
cst, err := config.Get()
|
||||
cstPtr, err := config.GetManyAs[models.Config]()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cst := *cstPtr
|
||||
|
||||
all, allErr := config.GetAll()
|
||||
hasKey := func(k string) bool {
|
||||
if allErr != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := all[k]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Apply defaults only when a key is missing.
|
||||
if !hasKey("sitename") {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if !hasKey("description") {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if !hasKey("theme") {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if !hasKey("o_auth_provider") {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if !hasKey("geo_ip_enabled") {
|
||||
cst.GeoIpEnabled = true
|
||||
}
|
||||
if !hasKey("geo_ip_provider") {
|
||||
cst.GeoIpProvider = "ip-api"
|
||||
}
|
||||
if !hasKey("notification_method") {
|
||||
cst.NotificationMethod = "none"
|
||||
}
|
||||
if !hasKey("record_enabled") {
|
||||
cst.RecordEnabled = true
|
||||
}
|
||||
if !hasKey("record_preserve_time") {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if !hasKey("ping_record_preserve_time") {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
|
||||
// Fallback defaults if we couldn't enumerate keys.
|
||||
if allErr != nil {
|
||||
if cst.Sitename == "" {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if cst.Description == "" {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if cst.Theme == "" {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if cst.OAuthProvider == "" {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if cst.RecordPreserveTime == 0 {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if cst.PingRecordPreserveTime == 0 {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
|
||||
+32
-12
@@ -1,5 +1,5 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
|
||||
|
||||
## 特徴
|
||||
|
||||
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
|
||||
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
|
||||
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
@@ -16,16 +17,15 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
## クイックスタート
|
||||
|
||||
### 0. クラウドホスティングによるワンクリック展開
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 1. ワンクリックインストールスクリプトを使用する
|
||||
|
||||
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
@@ -33,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker展開
|
||||
|
||||
1. データディレクトリを作成します:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -55,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
|
||||
|
||||
### 3. バイナリファイル展開
|
||||
|
||||
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komariを実行します:
|
||||
```bash
|
||||
@@ -66,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
|
||||
|
||||
|
||||
### 手動ビルド
|
||||
|
||||
#### 依存関係
|
||||
@@ -85,30 +86,49 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
|
||||
```bash
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 実行:
|
||||
3. 実行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
|
||||
|
||||
## フロントエンド開発ガイド
|
||||
|
||||
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## クライアントエージェント開発ガイド
|
||||
|
||||
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢献
|
||||
|
||||
IssueやPull Requestを歓迎します!
|
||||
|
||||
## 謝辞
|
||||
- こんなに暇でいられる自分に感謝
|
||||
- PRを提出し、テーマを作成してくれたすべての開発者
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
|
||||
|
||||
### 🚀 SharonNetworks スポンサー
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
|
||||
|
||||
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
|
||||
|
||||
### オープンソースコミュニティ
|
||||
|
||||
PR を送ってくれた方、テーマを作成してくれた全ての開発者
|
||||
|
||||
—— そして:こんなに暇でいられる自分に感謝
|
||||
|
||||
## Star履歴
|
||||
|
||||
|
||||
+32
-15
@@ -1,5 +1,5 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
|
||||
- **自託管**:完全掌控數據隱私,部署簡單。
|
||||
- **Web 介面**:直觀的監控儀表盤,易於使用。
|
||||
@@ -17,16 +18,14 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
|
||||
### 0. 容器雲一鍵部署
|
||||
|
||||
- 雨雲雲應用 - CNY 4.5/月
|
||||
- 雨雲雲應用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 1. 使用一鍵安裝腳本
|
||||
|
||||
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
|
||||
1. 建立資料目錄:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
|
||||
|
||||
### 3. 二進位檔案部署
|
||||
|
||||
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
|
||||
2. 執行 Komari:
|
||||
```bash
|
||||
@@ -67,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
|
||||
|
||||
|
||||
### 手工建置
|
||||
|
||||
#### 依賴
|
||||
@@ -86,33 +86,50 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
|
||||
```bash
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 執行:
|
||||
3. 執行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
|
||||
|
||||
## 前端開發指南
|
||||
|
||||
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 客戶端 Agent 開發指南
|
||||
|
||||
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢獻
|
||||
|
||||
歡迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鳴謝
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- 感謝我自己能這麼閒
|
||||
- 提交PR、製作主題的各位開發者
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
|
||||
|
||||
### 🚀 由 SharonNetworks 贊助
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 為您的業務起飛保駕護航!
|
||||
|
||||
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
|
||||
|
||||
### 開源社群
|
||||
|
||||
提交 PR、製作主題的各位開發者
|
||||
|
||||
—— 以及:感謝我自己能這麼閒
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
+33
-15
@@ -1,14 +1,15 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
@@ -17,16 +18,14 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 1. 使用一键安装脚本
|
||||
|
||||
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
|
||||
1. 创建数据目录:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
|
||||
### 3. 二进制文件部署
|
||||
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
```bash
|
||||
@@ -67,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
|
||||
|
||||
### 手工构建
|
||||
|
||||
#### 依赖
|
||||
@@ -86,31 +86,49 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
3. 运行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
默认监听 `25774` 端口,访问 `http://localhost:25774`。
|
||||
|
||||
## 前端开发指南
|
||||
|
||||
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
|
||||
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鸣谢
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- 感谢我自己能这么闲
|
||||
- 提交PR、制作主题的各位开发者
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
|
||||
### 🚀 由 SharonNetworks 赞助
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
|
||||
|
||||
### 开源社区
|
||||
|
||||
提交 PR、制作主题的各位开发者
|
||||
|
||||
—— 以及:感谢我自己能这么闲
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
module github.com/komari-monitor/komari
|
||||
|
||||
go 1.23.2
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -12,6 +16,9 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/image v0.35.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
@@ -20,11 +27,11 @@ require (
|
||||
|
||||
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
|
||||
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
|
||||
require golang.org/x/crypto v0.37.0 // indirect
|
||||
require golang.org/x/crypto v0.39.0 // indirect
|
||||
|
||||
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
|
||||
// golang.org/x/net vulnerable to Cross-site Scripting #4
|
||||
require golang.org/x/net v0.39.0 // indirect
|
||||
require golang.org/x/net v0.41.0 // indirect
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
@@ -33,20 +40,23 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.27 // indirect
|
||||
@@ -59,10 +69,10 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -17,14 +19,24 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -33,14 +45,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -87,7 +105,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -111,19 +128,39 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -131,6 +168,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,12 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if utils.VersionHash == "unknown" {
|
||||
logutil.SetupGlobalLogger(slog.LevelDebug)
|
||||
} else {
|
||||
logutil.SetupGlobalLogger(slog.LevelInfo)
|
||||
}
|
||||
|
||||
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
dist/*
|
||||
dist/*
|
||||
defaultTheme/*
|
||||
+201
-267
@@ -2,308 +2,242 @@ package public
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
//go:embed defaultTheme
|
||||
var PublicFS embed.FS
|
||||
|
||||
var DistFS fs.FS
|
||||
var RawIndexFile string
|
||||
// 常量定义
|
||||
const (
|
||||
DataDir = "./data"
|
||||
ThemesDir = "theme"
|
||||
FaviconFile = "favicon.ico"
|
||||
DefaultTheme = "default"
|
||||
|
||||
var IndexFile string
|
||||
// 主题内部结构定义
|
||||
DistDir = "dist" // 静态资源存放目录
|
||||
IndexFile = "index.html" // 相对于 DistDir
|
||||
)
|
||||
|
||||
func initIndex() {
|
||||
err := os.MkdirAll("./data/theme", 0755)
|
||||
if err != nil {
|
||||
log.Println("Failed to create theme directory:", err)
|
||||
return
|
||||
}
|
||||
dist, err := fs.Sub(PublicFS, "dist")
|
||||
if err != nil {
|
||||
log.Println("Failed to create dist subdirectory:", err)
|
||||
}
|
||||
DistFS = dist
|
||||
|
||||
indexFile, err := dist.Open("index.html")
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Println("index.html not exist, you may forget to put dist of frontend to public/dist")
|
||||
}
|
||||
log.Println("Failed to open index.html:", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = indexFile.Close()
|
||||
}()
|
||||
index, err := io.ReadAll(indexFile)
|
||||
if err != nil {
|
||||
log.Println("Failed to read index.html:", err)
|
||||
}
|
||||
RawIndexFile = string(index)
|
||||
}
|
||||
func UpdateIndex(cfg models.Config) {
|
||||
IndexFile = applyCustomizations(RawIndexFile, cfg)
|
||||
func init() {
|
||||
_ = os.MkdirAll("./data/theme", 0755)
|
||||
}
|
||||
|
||||
// applyCustomizations 应用自定义内容到HTML字符串
|
||||
func applyCustomizations(htmlContent string, cfg models.Config) string {
|
||||
var titleReplacement string
|
||||
if cfg.Sitename == "Komari" {
|
||||
titleReplacement = "<title>Komari Monitor</title>"
|
||||
} else {
|
||||
titleReplacement = fmt.Sprintf("<title>%s - Komari Monitor</title>", html.EscapeString(cfg.Sitename))
|
||||
// isSafePath 验证路径是否在指定的基础目录内,防止路径穿透攻击
|
||||
func isSafePath(basePath, targetPath string) bool {
|
||||
// 获取基础目录的绝对路径
|
||||
absBase, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
replaceMap := map[string]string{
|
||||
"<title>Komari Monitor</title>": titleReplacement,
|
||||
"A simple server monitor tool.": cfg.Description,
|
||||
"</head>": cfg.CustomHead + "</head>",
|
||||
"</body>": cfg.CustomBody + "</body>",
|
||||
// 清理目标路径,移除 ../ 等
|
||||
cleanTarget := filepath.Clean(targetPath)
|
||||
|
||||
// 拼接完整路径
|
||||
fullPath := filepath.Join(absBase, cleanTarget)
|
||||
|
||||
// 获取绝对路径
|
||||
absTarget, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := htmlContent
|
||||
for k, v := range replaceMap {
|
||||
updated = strings.Replace(updated, k, v, -1)
|
||||
// 检查目标路径是否以基础路径开头
|
||||
// 使用 filepath.Rel 更可靠地检查路径关系
|
||||
rel, err := filepath.Rel(absBase, absTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return updated
|
||||
|
||||
// 如果相对路径以 .. 开头,说明目标在基础目录之外
|
||||
return !strings.HasPrefix(rel, "..") && rel != ".."
|
||||
}
|
||||
|
||||
// Static 注册静态资源和 SPA 路由处理
|
||||
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
|
||||
initIndex()
|
||||
// 初始化嵌入式文件系统,指向 defaultTheme 根目录
|
||||
// 假设 defaultTheme 内部结构也是: dist/, theme.json 等
|
||||
defaultThemeFS, err := fs.Sub(PublicFS, "defaultTheme")
|
||||
if err != nil {
|
||||
panic("you may forget to put dist of frontend to public/defaultTheme/dist")
|
||||
}
|
||||
|
||||
// Serve favicon.ico: use local file if exists, fallback to embedded
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
if _, err := os.Stat("data/favicon.ico"); err == nil {
|
||||
c.File("data/favicon.ico")
|
||||
getConfig := func() map[string]any {
|
||||
cfg, _ := config.GetMany(map[string]any{
|
||||
config.DescriptionKey: "A simple server monitor tool.",
|
||||
config.CustomHeadKey: "",
|
||||
config.CustomBodyKey: "",
|
||||
config.SitenameKey: "Komari Monitor",
|
||||
config.ThemeKey: DefaultTheme,
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 核心逻辑:获取文件内容
|
||||
// filePath: 相对于主题根目录的路径 (例如 "theme.json" 或 "dist/assets/a.js")
|
||||
// 返回: content, contentType, exists
|
||||
getFileContent := func(themeID string, relativePath string) ([]byte, string, bool) {
|
||||
cleanPath := strings.TrimPrefix(relativePath, "/")
|
||||
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
|
||||
if themeID != DefaultTheme {
|
||||
if strings.Contains(themeID, "..") || strings.Contains(themeID, "/") || strings.Contains(themeID, "\\") {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
themeBasePath := filepath.Join(DataDir, ThemesDir, themeID)
|
||||
|
||||
if !isSafePath(themeBasePath, cleanPath) {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
localPath := filepath.Join(themeBasePath, cleanPath)
|
||||
// 检查文件是否存在且不是目录
|
||||
if info, err := os.Stat(localPath); err == nil && !info.IsDir() {
|
||||
content, err := os.ReadFile(localPath)
|
||||
if err == nil {
|
||||
return content, mime.TypeByExtension(filepath.Ext(localPath)), true
|
||||
}
|
||||
}
|
||||
// 本地文件不存在,或读取失败 -> 继续向下回退
|
||||
}
|
||||
|
||||
// 2. 尝试从嵌入式 defaultTheme/{cleanPath} 读取
|
||||
// fs.ReadFile 处理 embed 路径时使用 "/"
|
||||
embedPath := filepath.ToSlash(cleanPath)
|
||||
|
||||
if strings.Contains(embedPath, "..") {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
if content, err := fs.ReadFile(defaultThemeFS, embedPath); err == nil {
|
||||
return content, mime.TypeByExtension(filepath.Ext(embedPath)), true
|
||||
}
|
||||
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// 核心逻辑:渲染 Index.html
|
||||
serveIndex := func(c *gin.Context) {
|
||||
reqPath := c.Request.URL.Path
|
||||
cfg := getConfig()
|
||||
|
||||
currentTheme := cfg[config.ThemeKey].(string)
|
||||
shouldReplace := true
|
||||
|
||||
// 特殊页面:强制使用 default 主题,且不进行内容替换
|
||||
if strings.HasPrefix(reqPath, "/admin") || strings.HasPrefix(reqPath, "/terminal") {
|
||||
currentTheme = DefaultTheme
|
||||
shouldReplace = false
|
||||
}
|
||||
|
||||
// 获取 dist/index.html (相对于主题根目录)
|
||||
targetFile := path.Join(DistDir, IndexFile)
|
||||
content, _, exists := getFileContent(currentTheme, targetFile)
|
||||
|
||||
if !exists {
|
||||
c.String(http.StatusNotFound, "Index file missing (checked %s/dist/index.html and default).", currentTheme)
|
||||
return
|
||||
}
|
||||
f, err := DistFS.Open("favicon.ico")
|
||||
if err != nil {
|
||||
|
||||
// 如果不替换,直接返回原始内容
|
||||
if !shouldReplace {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行 HTML 内容替换
|
||||
htmlStr := string(content)
|
||||
replacer := strings.NewReplacer(
|
||||
"<title>Komari Monitor</title>", "<title>"+cfg[config.SitenameKey].(string)+"</title>",
|
||||
"A simple server monitor tool.", cfg[config.DescriptionKey].(string),
|
||||
"</head>", cfg[config.CustomHeadKey].(string)+"</head>",
|
||||
"</body>", cfg[config.CustomBodyKey].(string)+"</body>",
|
||||
)
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(replacer.Replace(htmlStr)))
|
||||
}
|
||||
|
||||
// ================= 路由定义 =================
|
||||
|
||||
// 1. Favicon 优先策略
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
// 优先:./data/favicon.ico
|
||||
localFavicon := filepath.Join(DataDir, FaviconFile)
|
||||
if _, err := os.Stat(localFavicon); err == nil {
|
||||
c.File(localFavicon)
|
||||
return
|
||||
}
|
||||
|
||||
// 其次:当前主题的 dist/favicon.ico 或 theme_root/favicon.ico ?
|
||||
// 通常构建后的资源在 dist 中,这里假设优先找 dist 内的,如果你的 favicon 在根目录,去掉 DistDir 拼接即可
|
||||
cfg := getConfig()
|
||||
themeFaviconPath := path.Join(DistDir, FaviconFile)
|
||||
content, mimeType, exists := getFileContent(cfg[config.ThemeKey].(string), themeFaviconPath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
|
||||
// 2. 静态资源路由 /themes/:id/*path
|
||||
// 允许访问 /themes/MyTheme/theme.json 和 /themes/MyTheme/dist/assets/a.js
|
||||
r.GET("/themes/:id/*path", func(c *gin.Context) {
|
||||
themeID := c.Param("id")
|
||||
// c.Param("path") 包含了开头的 /,getFileContent 会处理
|
||||
filePath := c.Param("path")
|
||||
|
||||
content, mimeType, exists := getFileContent(themeID, filePath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
|
||||
// 3. SPA 路由 (noRoute)
|
||||
noRoute(func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/x-icon", data)
|
||||
})
|
||||
// r.GET("/manifest.json", func(c *gin.Context) {
|
||||
// cfg, err := config.Get()
|
||||
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
|
||||
// // 使用默认主题的manifest.json
|
||||
// f, err := DistFS.Open("manifest.json")
|
||||
// if err != nil {
|
||||
// c.Status(http.StatusNotFound)
|
||||
// return
|
||||
// }
|
||||
// defer f.Close()
|
||||
// data, err := io.ReadAll(f)
|
||||
// if err != nil {
|
||||
// c.Status(http.StatusInternalServerError)
|
||||
// return
|
||||
// }
|
||||
// c.Data(http.StatusOK, "application/json", data)
|
||||
// } else {
|
||||
// // 使用自定义主题的manifest.json
|
||||
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
|
||||
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
|
||||
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
|
||||
// } else {
|
||||
// c.File(themePath)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// Serve theme files from data/theme directory (for theme previews and static assets)
|
||||
r.Static("/themes", "./data/theme")
|
||||
reqPath := c.Request.URL.Path
|
||||
cfg := getConfig()
|
||||
currentTheme := cfg[config.ThemeKey].(string)
|
||||
|
||||
// 使用传入的noRoute函数来处理未匹配的路由
|
||||
noRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// SPA 静态资源回退
|
||||
distPath := path.Join(DistDir, reqPath)
|
||||
|
||||
// 对于admin页面,直接使用embedded文件
|
||||
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
|
||||
serveFromEmbedded(c, path)
|
||||
content, mimeType, exists := getFileContent(currentTheme, distPath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前主题配置
|
||||
cfg, err := config.Get()
|
||||
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
|
||||
// 使用默认主题(embedded文件)
|
||||
serveFromEmbedded(c, path)
|
||||
return
|
||||
}
|
||||
// 如果资源不存在,且路径包含扩展名 (如 .js, .css, .png),则返回 404
|
||||
// 避免将 index.html 作为 js 文件返回导致 "Failed to fetch dynamically imported module"
|
||||
//ext := filepath.Ext(reqPath)
|
||||
//if ext != "" && ext != ".html" {
|
||||
// c.Status(http.StatusNotFound)
|
||||
// return
|
||||
//}
|
||||
|
||||
// 使用自定义主题
|
||||
serveFromTheme(c, path, cfg.Theme)
|
||||
// 路由 (如 /dashboard, /settings) -> 返回 index.html
|
||||
serveIndex(c)
|
||||
})
|
||||
}
|
||||
|
||||
// serveFromEmbedded 从嵌入的文件系统服务文件
|
||||
func serveFromEmbedded(c *gin.Context, path string) {
|
||||
// 处理静态资源文件夹
|
||||
// folders := []string{"assets", "images", "streamer", "static"}
|
||||
// for _, folder := range folders {
|
||||
// if strings.HasPrefix(path, "/"+folder+"/") {
|
||||
// c.Header("Cache-Control", "public, max-age=15552000")
|
||||
// sub, err := fs.Sub(DistFS, folder)
|
||||
// if err == nil {
|
||||
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
|
||||
// file, err := sub.Open(relativePath)
|
||||
// if err == nil {
|
||||
// defer file.Close()
|
||||
// data, err := io.ReadAll(file)
|
||||
// if err == nil {
|
||||
// // 设置正确的Content-Type
|
||||
// contentType := getContentType(path)
|
||||
// c.Header("Content-Type", contentType)
|
||||
// c.Data(http.StatusOK, contentType, data)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 如果文件存在,直接返回文件内容
|
||||
cleanPath := strings.TrimPrefix(path, "/")
|
||||
file, err := DistFS.Open(cleanPath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err == nil {
|
||||
contentType := getContentType(path)
|
||||
c.Header("Content-Type", contentType)
|
||||
// 静态资源设置缓存
|
||||
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=15552000")
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理HTML页面
|
||||
if c.Request.Method != "GET" && c.Request.Method != "POST" {
|
||||
c.Status(405)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Status(200)
|
||||
|
||||
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
|
||||
c.Writer.WriteString(RawIndexFile)
|
||||
} else {
|
||||
c.Writer.WriteString(IndexFile)
|
||||
}
|
||||
|
||||
c.Writer.Flush()
|
||||
c.Writer.WriteHeaderNow()
|
||||
}
|
||||
|
||||
// serveFromTheme 从主题目录服务文件
|
||||
func serveFromTheme(c *gin.Context, path string, themeName string) {
|
||||
themeDir := filepath.Join("./data/theme", themeName, "dist")
|
||||
|
||||
// 构建完整的文件路径
|
||||
var filePath string
|
||||
if path == "/" || path == "" {
|
||||
filePath = filepath.Join(themeDir, "index.html")
|
||||
} else {
|
||||
filePath = filepath.Join(themeDir, strings.TrimPrefix(path, "/"))
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
// 如果文件不存在,尝试serve index.html(用于SPA路由)
|
||||
if !strings.Contains(path, ".") {
|
||||
indexPath := filepath.Join(themeDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
serveThemeIndexWithCustomizations(c, indexPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 回退到默认主题
|
||||
serveFromEmbedded(c, path)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=15552000")
|
||||
}
|
||||
|
||||
// 如果是index.html文件,需要处理自定义内容
|
||||
if strings.HasSuffix(filePath, "index.html") {
|
||||
serveThemeIndexWithCustomizations(c, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
// serveThemeIndexWithCustomizations 服务主题的index.html并应用自定义内容
|
||||
func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
|
||||
// 读取主题的index.html文件
|
||||
data, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配置以应用自定义内容
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
// 如果获取配置失败,直接返回原始文件
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Data(http.StatusOK, "text/html", data)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用通用的自定义内容应用函数
|
||||
content := applyCustomizations(string(data), cfg)
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Data(http.StatusOK, "text/html", []byte(content))
|
||||
}
|
||||
|
||||
// getContentType 根据文件扩展名返回Content-Type
|
||||
func getContentType(path string) string {
|
||||
if strings.HasSuffix(path, ".css") {
|
||||
return "text/css"
|
||||
} else if strings.HasSuffix(path, ".js") {
|
||||
return "application/javascript"
|
||||
} else if strings.HasSuffix(path, ".png") {
|
||||
return "image/png"
|
||||
} else if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
|
||||
return "image/jpeg"
|
||||
} else if strings.HasSuffix(path, ".gif") {
|
||||
return "image/gif"
|
||||
} else if strings.HasSuffix(path, ".svg") {
|
||||
return "image/svg+xml"
|
||||
} else if strings.HasSuffix(path, ".ico") {
|
||||
return "image/x-icon"
|
||||
} else if strings.HasSuffix(path, ".woff") {
|
||||
return "font/woff"
|
||||
} else if strings.HasSuffix(path, ".woff2") {
|
||||
return "font/woff2"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
+15
-6
@@ -3,14 +3,17 @@
|
||||
## English
|
||||
|
||||
### Frontend Repository
|
||||
|
||||
- **Frontend project repository**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### Build Requirements
|
||||
|
||||
1. Clone the frontend repository and build the static files
|
||||
2. Copy the generated static files to the `dist` folder in the Komari backend project root directory
|
||||
3. Ensure the `dist` folder contains `index.html`
|
||||
2. Copy the generated static files to the `defaultTheme` folder in the Komari backend project root directory
|
||||
3. Ensure the `defaultTheme/dist` folder contains `index.html`
|
||||
|
||||
### Important Note
|
||||
|
||||
⚠️ **The projects under Akizon77's personal repository are no longer maintained. Please use the projects under the organization (komari-monitor).**
|
||||
|
||||
---
|
||||
@@ -18,14 +21,17 @@
|
||||
## 中文
|
||||
|
||||
### 前端项目仓库
|
||||
|
||||
- **前端项目地址**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### 构建要求
|
||||
|
||||
1. 克隆前端仓库并构建静态文件
|
||||
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
|
||||
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `defaultTheme` 文件夹
|
||||
3. 确保 `dist` 文件夹内包含 `index.html`
|
||||
|
||||
### 重要提醒
|
||||
|
||||
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
|
||||
|
||||
---
|
||||
@@ -33,14 +39,17 @@
|
||||
## 日本語
|
||||
|
||||
### フロントエンドプロジェクトリポジトリ
|
||||
|
||||
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### ビルド要件
|
||||
|
||||
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
|
||||
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
|
||||
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `defaultTheme` フォルダーにコピーする
|
||||
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
|
||||
|
||||
### 重要な注意事項
|
||||
|
||||
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
|
||||
|
||||
---
|
||||
@@ -57,5 +66,5 @@ npm install
|
||||
npm run build
|
||||
|
||||
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
|
||||
cp -r dist /path/to/komari/public/
|
||||
```
|
||||
cp -r dist /path/to/komari/public/defaultTheme
|
||||
```
|
||||
+20
-17
@@ -1,13 +1,13 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
@@ -52,60 +52,63 @@ func GetRegionUnicodeEmoji(isoCode string) string {
|
||||
}
|
||||
|
||||
func InitGeoIp() {
|
||||
conf, err := config.Get()
|
||||
conf, err := config.GetMany(map[string]any{
|
||||
config.GeoIpEnabledKey: true,
|
||||
config.GeoIpProviderKey: "ipinfo",
|
||||
})
|
||||
if err != nil {
|
||||
panic("Failed to get configuration for GeoIP: " + err.Error())
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
if !conf[config.GeoIpEnabledKey].(bool) {
|
||||
return
|
||||
}
|
||||
switch conf.GeoIpProvider {
|
||||
switch conf[config.GeoIpProviderKey].(string) {
|
||||
case "mmdb":
|
||||
NewCurrentProvider, err := NewMaxMindGeoIPService()
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
|
||||
slog.Error("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.")
|
||||
slog.Error("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())
|
||||
slog.Error("Failed to initialize ip-api service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using ip-api.com as GeoIP provider.")
|
||||
slog.Info("Using ip-api.com as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
|
||||
slog.Info("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())
|
||||
slog.Error("Failed to initialize GeoJS service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using geojs.io as GeoIP provider.")
|
||||
slog.Info("Using geojs.io as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
|
||||
slog.Info("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())
|
||||
slog.Error("Failed to initialize IPInfo service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using ipinfo.io as GeoIP provider.")
|
||||
slog.Info("Using ipinfo.io as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
|
||||
slog.Info("Failed to initialize IPInfo service, using EmptyProvider instead.")
|
||||
}
|
||||
default:
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
@@ -133,7 +136,7 @@ func UpdateDatabase() error {
|
||||
err := CurrentProvider.UpdateDatabase()
|
||||
if err == nil {
|
||||
geoCache.Flush()
|
||||
log.Println("GeoIP cache cleared due to database update.")
|
||||
slog.Info("GeoIP cache cleared due to database update.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
+13
-1
@@ -17,11 +17,13 @@ func Parse(v any) []Item {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
var allowTypes = []string{"option", "richtext"}
|
||||
|
||||
var items []Item
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
typ := field.Tag.Get("type")
|
||||
if typ != "option" {
|
||||
if !contains(allowTypes, typ) {
|
||||
typ = field.Type.Name()
|
||||
}
|
||||
item := Item{
|
||||
@@ -39,3 +41,13 @@ func Parse(v any) []Item {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// contains reports whether s is present in slice.
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 计算延迟
|
||||
latency := time.Since(start)
|
||||
|
||||
// 获取状态码
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
statusCodeColored := func(code int) string {
|
||||
if code >= 500 {
|
||||
return Red("%d", code)
|
||||
} else if code >= 400 {
|
||||
return Yellow("%d", code)
|
||||
} else if code >= 300 {
|
||||
return Cyan("%d", code)
|
||||
} else {
|
||||
return Green("%d", code)
|
||||
}
|
||||
}(statusCode)
|
||||
|
||||
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
|
||||
if query != "" {
|
||||
msg += "?" + query
|
||||
}
|
||||
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
msg += " | " + c.Errors.String()
|
||||
}
|
||||
|
||||
handler := slog.Default().Handler()
|
||||
var level slog.Level
|
||||
if statusCode >= 500 {
|
||||
level = slog.LevelError
|
||||
} else {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
}
|
||||
}
|
||||
|
||||
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
|
||||
func GinRecovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
|
||||
handler := slog.Default().Handler()
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN"))
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
c.AbortWithStatus(500)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
komari_utils "github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/utils"
|
||||
)
|
||||
|
||||
// GormLogger 实现 gorm.io/gorm/logger.Interface
|
||||
type GormLogger struct {
|
||||
SlowThreshold time.Duration
|
||||
IgnoreRecordNotFoundError bool
|
||||
LogLevel gormlogger.LogLevel
|
||||
}
|
||||
|
||||
// NewGormLogger 创建 GORM logger
|
||||
func NewGormLogger() *GormLogger {
|
||||
return &GormLogger{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
LogLevel: func(hash string) gormlogger.LogLevel {
|
||||
if hash == "unknown" {
|
||||
return gormlogger.Info
|
||||
}
|
||||
return gormlogger.Silent
|
||||
}(komari_utils.VersionHash),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
||||
newlogger := *l
|
||||
newlogger.LogLevel = level
|
||||
return &newlogger
|
||||
}
|
||||
|
||||
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Info {
|
||||
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Warn {
|
||||
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Error {
|
||||
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.LogLevel <= gormlogger.Silent {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := fc()
|
||||
|
||||
fileWithLineNum := utils.FileWithLineNum()
|
||||
|
||||
handler := slog.Default().Handler()
|
||||
|
||||
switch {
|
||||
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case l.LogLevel >= gormlogger.Info:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Green(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Yellow(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Red(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Blue(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Cyan(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Gray(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func White(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
type LogHandler struct {
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
group string
|
||||
}
|
||||
|
||||
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
|
||||
return &LogHandler{w: w, level: level, group: ""}
|
||||
}
|
||||
|
||||
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
var file string
|
||||
var line int
|
||||
if r.PC != 0 {
|
||||
fs := runtime.CallersFrames([]uintptr{r.PC})
|
||||
f, _ := fs.Next()
|
||||
file = f.File
|
||||
line = f.Line
|
||||
}
|
||||
|
||||
timeStr := r.Time.Format("2006/01/02 15:04:05")
|
||||
|
||||
// 检查是否有分组信息(从 attributes 中获取)
|
||||
group := h.group
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key == "_group" {
|
||||
group = a.Value.String()
|
||||
return false // 停止迭代
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
levelStr := ""
|
||||
switch r.Level {
|
||||
case slog.LevelDebug:
|
||||
if group != "" {
|
||||
levelStr = Cyan("[DEBUG/%s]", group)
|
||||
} else {
|
||||
levelStr = Cyan("[DEBUG]")
|
||||
}
|
||||
case slog.LevelInfo:
|
||||
if group != "" {
|
||||
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
|
||||
} else {
|
||||
levelStr = Green("[INFO]")
|
||||
}
|
||||
case slog.LevelWarn:
|
||||
if group != "" {
|
||||
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
|
||||
} else {
|
||||
levelStr = Yellow("[WARN]")
|
||||
}
|
||||
case slog.LevelError:
|
||||
if group != "" {
|
||||
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
|
||||
} else {
|
||||
levelStr = Red("[ERROR]")
|
||||
}
|
||||
}
|
||||
|
||||
var msg string
|
||||
if file != "" {
|
||||
msg = fmt.Sprintf("%s %s %s %s",
|
||||
timeStr,
|
||||
levelStr,
|
||||
r.Message,
|
||||
Gray(fmt.Sprintf("(%s:%d)", file, line)))
|
||||
} else {
|
||||
msg = fmt.Sprintf("%s %s %s",
|
||||
timeStr,
|
||||
levelStr,
|
||||
r.Message)
|
||||
}
|
||||
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key != "_group" { // 跳过内部使用的 _group 属性
|
||||
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
msg += "\n"
|
||||
_, err := h.w.Write([]byte(msg))
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *LogHandler) WithGroup(name string) slog.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
|
||||
func SetupGlobalLogger(level slog.Level) {
|
||||
handler := NewHandler(os.Stdout, level)
|
||||
logger := slog.New(handler)
|
||||
|
||||
// 设置 slog 默认 logger
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// 设置标准库 log 使用 slog
|
||||
stdlog.SetOutput(os.Stdout)
|
||||
stdlog.SetFlags(0) // 清除默认标志
|
||||
stdlog.SetPrefix("")
|
||||
|
||||
// 替换标准库 log 的输出为 slog handler
|
||||
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
|
||||
}
|
||||
|
||||
// writerAdapter 将标准库 log 的输出适配到 slog
|
||||
type writerAdapter struct {
|
||||
handler slog.Handler
|
||||
level slog.Level
|
||||
}
|
||||
|
||||
func (w *writerAdapter) Write(p []byte) (n int, err error) {
|
||||
msg := string(p)
|
||||
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
|
||||
msg = msg[:len(msg)-1]
|
||||
}
|
||||
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
|
||||
|
||||
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
|
||||
return len(p), w.handler.Handle(context.Background(), r)
|
||||
}
|
||||
|
||||
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
|
||||
func GetWriter() io.Writer {
|
||||
handler := slog.Default().Handler()
|
||||
return &writerAdapter{handler: handler, level: slog.LevelInfo}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/email"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/serverchan3"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
if serverURL == "" {
|
||||
serverURL = "https://api.day.app"
|
||||
}
|
||||
|
||||
|
||||
// 确保 URL 以正确的格式结尾
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
requestURL := fmt.Sprintf("%s/push", serverURL)
|
||||
@@ -96,8 +96,8 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -114,11 +114,5 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*BarkSender)(nil)
|
||||
@@ -1,10 +1,16 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
@@ -13,6 +19,59 @@ type EmailSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
|
||||
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:", "User Name":
|
||||
return []byte(a.username), nil
|
||||
case "Password:", "password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
// 某些服务器可能发送 base64 编码的提示
|
||||
// 尝试返回用户名或密码
|
||||
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
|
||||
if strings.Contains(prompt, "user") {
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
|
||||
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
|
||||
type plainAuthWithoutCheck struct {
|
||||
identity string
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
}
|
||||
|
||||
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
|
||||
return "PLAIN", resp, nil
|
||||
}
|
||||
|
||||
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
return nil, fmt.Errorf("unexpected server challenge")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *EmailSender) GetName() string {
|
||||
return "email"
|
||||
}
|
||||
@@ -35,68 +94,192 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("email sending is not fully configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth(
|
||||
"",
|
||||
e.Addition.Username,
|
||||
e.Addition.Password,
|
||||
e.Addition.Host,
|
||||
)
|
||||
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
|
||||
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
|
||||
var auth smtp.Auth
|
||||
if e.Addition.UseLoginAuth {
|
||||
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
|
||||
auth = &loginAuth{
|
||||
username: e.Addition.Username,
|
||||
password: e.Addition.Password,
|
||||
host: e.Addition.Host,
|
||||
}
|
||||
} else {
|
||||
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
|
||||
auth = &plainAuthWithoutCheck{
|
||||
identity: "",
|
||||
username: e.Addition.Username,
|
||||
password: e.Addition.Password,
|
||||
host: e.Addition.Host,
|
||||
}
|
||||
}
|
||||
|
||||
msg := []byte("To: " + e.Addition.Receiver + "\r\n" +
|
||||
"From: " + e.Addition.Sender + "\r\n" +
|
||||
"Subject: " + title + "\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
message)
|
||||
// Parse sender address (for MAIL FROM and header)
|
||||
var senderAddr string
|
||||
var senderHeader string
|
||||
if addr, err := mail.ParseAddress(e.Addition.Sender); err == nil {
|
||||
senderAddr = addr.Address
|
||||
senderHeader = addr.String()
|
||||
} else {
|
||||
// Fallback: use raw string
|
||||
senderAddr = e.Addition.Sender
|
||||
senderHeader = e.Addition.Sender
|
||||
}
|
||||
|
||||
// Parse recipients (support comma-separated list)
|
||||
var rcptList []string
|
||||
var rcptHeaderParts []string
|
||||
if addrs, err := mail.ParseAddressList(e.Addition.Receiver); err == nil {
|
||||
for _, a := range addrs {
|
||||
rcptList = append(rcptList, a.Address)
|
||||
rcptHeaderParts = append(rcptHeaderParts, a.String())
|
||||
}
|
||||
} else {
|
||||
// Fallback simple split
|
||||
parts := strings.Split(e.Addition.Receiver, ",")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if a, err := mail.ParseAddress(p); err == nil {
|
||||
rcptList = append(rcptList, a.Address)
|
||||
rcptHeaderParts = append(rcptHeaderParts, a.String())
|
||||
} else {
|
||||
rcptList = append(rcptList, p)
|
||||
rcptHeaderParts = append(rcptHeaderParts, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rcptList) == 0 {
|
||||
return fmt.Errorf("no valid recipient address parsed")
|
||||
}
|
||||
|
||||
// RFC 2047 encode subject if non-ASCII
|
||||
encodedSubject := mime.QEncoding.Encode("UTF-8", title)
|
||||
|
||||
// Encode body as quoted-printable to be safe with UTF-8 on servers lacking 8BITMIME
|
||||
var bodyBuf bytes.Buffer
|
||||
qp := quotedprintable.NewWriter(&bodyBuf)
|
||||
if _, err := qp.Write([]byte(message)); err != nil {
|
||||
return fmt.Errorf("failed to encode body: %w", err)
|
||||
}
|
||||
if err := qp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to finalize body encoding: %w", err)
|
||||
}
|
||||
|
||||
contentType := "text/plain; charset=UTF-8"
|
||||
// 检测模板是否包含HTML
|
||||
trimmedMsg := strings.TrimSpace(message)
|
||||
if strings.Contains(strings.ToLower(trimmedMsg), "<html") ||
|
||||
strings.Contains(strings.ToLower(trimmedMsg), "<!doctype") ||
|
||||
(strings.Contains(trimmedMsg, "<div") && strings.Contains(trimmedMsg, "</div>")) {
|
||||
contentType = "text/html; charset=UTF-8"
|
||||
}
|
||||
|
||||
// Compose headers
|
||||
headers := []string{
|
||||
"To: " + strings.Join(rcptHeaderParts, ", "),
|
||||
"From: " + senderHeader,
|
||||
"Subject: " + encodedSubject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: " + contentType,
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Date: " + time.Now().Format(time.RFC1123Z),
|
||||
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
|
||||
}
|
||||
|
||||
fullMsg := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyBuf.String())
|
||||
|
||||
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
|
||||
|
||||
if e.Addition.UseSSL {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
// Use TLS. If port is 465, prefer implicit TLS. Otherwise, use STARTTLS.
|
||||
if e.Addition.Port == 465 {
|
||||
// Implicit TLS (SMTPS)
|
||||
tlsCfg := &tls.Config{ServerName: e.Addition.Host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to establish implicit TLS connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
|
||||
return fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
c, err := smtp.NewClient(conn, e.Addition.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client over TLS: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Authenticate
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
// Set the sender and recipient
|
||||
if err = c.Mail(e.Addition.Sender); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
if err = c.Rcpt(e.Addition.Receiver); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
if err = c.Mail(senderAddr); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
for _, rcpt := range rcptList {
|
||||
if err = c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("failed to add recipient %s: %w", rcpt, 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)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
if _, err = w.Write(fullMsg); err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
} else {
|
||||
// STARTTLS
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
return c.Quit()
|
||||
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
|
||||
return fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
if err = c.Mail(senderAddr); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
for _, rcpt := range rcptList {
|
||||
if err = c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("failed to add recipient %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
if _, err = w.Write(fullMsg); err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
} else {
|
||||
// Send without SSL/TLS (less secure)
|
||||
// Send without SSL/TLS (less secure). We still reuse the composed message and parsed addresses.
|
||||
return smtp.SendMail(
|
||||
addr,
|
||||
auth,
|
||||
e.Addition.Sender,
|
||||
[]string{e.Addition.Receiver},
|
||||
msg,
|
||||
senderAddr,
|
||||
rcptList,
|
||||
fullMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
Host string `json:"host" required:"true"`
|
||||
Port int `json:"port" required:"true" default:"587"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Sender string `json:"sender"`
|
||||
Receiver string `json:"receiver"`
|
||||
UseSSL bool `json:"use_ssl" default:"false"`
|
||||
Host string `json:"host" required:"true"`
|
||||
Port int `json:"port" required:"true" default:"587"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Sender string `json:"sender"`
|
||||
Receiver string `json:"receiver"`
|
||||
UseSSL bool `json:"use_ssl" default:"false"`
|
||||
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package factory
|
||||
|
||||
import "github.com/komari-monitor/komari/database/models"
|
||||
|
||||
type IMessageSender interface {
|
||||
GetName() string
|
||||
// 请务必返回 &Configuration{} 的指针
|
||||
@@ -9,6 +11,11 @@ type IMessageSender interface {
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
|
||||
type IEventMessageSender interface {
|
||||
SendEvent(event models.EventMessage) error
|
||||
}
|
||||
|
||||
type Configuration interface{}
|
||||
|
||||
type MessageSenderConstructor func() IMessageSender
|
||||
@@ -0,0 +1,301 @@
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// createFetchFunction 创建一个 fetch API 实现
|
||||
func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
panic(j.vm.NewTypeError("fetch requires at least 1 argument"))
|
||||
}
|
||||
|
||||
url := call.Argument(0).String()
|
||||
|
||||
// 解析选项
|
||||
options := map[string]interface{}{
|
||||
"method": "GET",
|
||||
"headers": make(map[string]string),
|
||||
"body": "",
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 1 {
|
||||
optObj := call.Argument(1).ToObject(j.vm)
|
||||
if optObj != nil {
|
||||
if method := optObj.Get("method"); method != nil && !goja.IsUndefined(method) {
|
||||
options["method"] = method.String()
|
||||
}
|
||||
if headers := optObj.Get("headers"); headers != nil && !goja.IsUndefined(headers) {
|
||||
headersObj := headers.ToObject(j.vm)
|
||||
if headersObj != nil {
|
||||
headerMap := make(map[string]string)
|
||||
for _, key := range headersObj.Keys() {
|
||||
headerMap[key] = headersObj.Get(key).String()
|
||||
}
|
||||
options["headers"] = headerMap
|
||||
}
|
||||
}
|
||||
if body := optObj.Get("body"); body != nil && !goja.IsUndefined(body) {
|
||||
options["body"] = body.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Promise
|
||||
promise, resolve, reject := j.vm.NewPromise()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("fetch panic: %v", r)))
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建 HTTP 请求
|
||||
method := options["method"].(string)
|
||||
var body io.Reader
|
||||
if options["body"].(string) != "" {
|
||||
body = strings.NewReader(options["body"].(string))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Failed to create request: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
headers := options["headers"].(map[string]string)
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Failed to read response: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建响应对象
|
||||
responseObj := j.vm.NewObject()
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", resp.Status)
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
|
||||
// 响应头
|
||||
headersObj := j.vm.NewObject()
|
||||
for key, values := range resp.Header {
|
||||
if len(values) > 0 {
|
||||
headersObj.Set(key, values[0])
|
||||
}
|
||||
}
|
||||
responseObj.Set("headers", headersObj)
|
||||
|
||||
// text() 方法
|
||||
responseObj.Set("text", func(goja.FunctionCall) goja.Value {
|
||||
textPromise, textResolve, _ := j.vm.NewPromise()
|
||||
textResolve(j.vm.ToValue(string(bodyBytes)))
|
||||
return j.vm.ToValue(textPromise)
|
||||
})
|
||||
|
||||
// json() 方法
|
||||
responseObj.Set("json", func(goja.FunctionCall) goja.Value {
|
||||
jsonPromise, jsonResolve, jsonReject := j.vm.NewPromise()
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
jsonReject(j.vm.ToValue(fmt.Sprintf("Failed to parse JSON: %v", err)))
|
||||
} else {
|
||||
jsonResolve(j.vm.ToValue(result))
|
||||
}
|
||||
return j.vm.ToValue(jsonPromise)
|
||||
})
|
||||
|
||||
resolve(responseObj)
|
||||
}()
|
||||
|
||||
return j.vm.ToValue(promise)
|
||||
}
|
||||
}
|
||||
|
||||
// createXHRConstructor 创建一个 XMLHttpRequest 构造函数
|
||||
func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *goja.Object {
|
||||
return func(call goja.ConstructorCall) *goja.Object {
|
||||
xhr := call.This
|
||||
|
||||
// 内部状态
|
||||
var method, url string
|
||||
var headers = make(map[string]string)
|
||||
var requestBody string
|
||||
var async = true
|
||||
|
||||
// readyState
|
||||
xhr.Set("readyState", 0)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", "")
|
||||
xhr.Set("responseText", "")
|
||||
xhr.Set("response", "")
|
||||
|
||||
// 事件处理器
|
||||
xhr.Set("onreadystatechange", goja.Null())
|
||||
xhr.Set("onload", goja.Null())
|
||||
xhr.Set("onerror", goja.Null())
|
||||
|
||||
// open 方法
|
||||
xhr.Set("open", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
panic(j.vm.NewTypeError("open requires at least 2 arguments"))
|
||||
}
|
||||
method = call.Argument(0).String()
|
||||
url = call.Argument(1).String()
|
||||
if len(call.Arguments) > 2 {
|
||||
async = call.Argument(2).ToBoolean()
|
||||
}
|
||||
xhr.Set("readyState", 1)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// setRequestHeader 方法
|
||||
xhr.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
panic(j.vm.NewTypeError("setRequestHeader requires 2 arguments"))
|
||||
}
|
||||
key := call.Argument(0).String()
|
||||
value := call.Argument(1).String()
|
||||
headers[key] = value
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// send 方法
|
||||
xhr.Set("send", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
|
||||
requestBody = call.Argument(0).String()
|
||||
}
|
||||
|
||||
sendFunc := func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", fmt.Sprintf("Error: %v", r))
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建请求
|
||||
var body io.Reader
|
||||
if requestBody != "" {
|
||||
body = bytes.NewReader([]byte(requestBody))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
xhr.Set("readyState", 2)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
xhr.Set("readyState", 3)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", resp.StatusCode)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
|
||||
// 完成
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", resp.StatusCode)
|
||||
xhr.Set("statusText", resp.Status)
|
||||
xhr.Set("responseText", string(bodyBytes))
|
||||
xhr.Set("response", string(bodyBytes))
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
j.callHandler(xhr, "onload")
|
||||
}
|
||||
|
||||
if async {
|
||||
go sendFunc()
|
||||
} else {
|
||||
sendFunc()
|
||||
}
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// getAllResponseHeaders 方法
|
||||
xhr.Set("getAllResponseHeaders", func(call goja.FunctionCall) goja.Value {
|
||||
return j.vm.ToValue("")
|
||||
})
|
||||
|
||||
// getResponseHeader 方法
|
||||
xhr.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// callHandler 调用事件处理器
|
||||
func (j *JavaScriptSender) callHandler(obj *goja.Object, handlerName string) {
|
||||
handler := obj.Get(handlerName)
|
||||
if handler != nil && !goja.IsUndefined(handler) && !goja.IsNull(handler) {
|
||||
if fn, ok := goja.AssertFunction(handler); ok {
|
||||
fn(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type JavaScriptSender struct {
|
||||
Addition
|
||||
vm *goja.Runtime
|
||||
noopProgram *goja.Program
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) GetName() string {
|
||||
return "Javascript"
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
|
||||
return &j.Addition
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) Init() error {
|
||||
if j.Addition.Script == "" {
|
||||
return errors.New("JavaScript script is empty")
|
||||
}
|
||||
|
||||
// 创建 JavaScript 运行时
|
||||
j.vm = goja.New()
|
||||
|
||||
// 预编译一个 no-op 程序,用于驱动微任务队列
|
||||
prog, errc := goja.Compile("noop.js", "void 0", false)
|
||||
if errc == nil {
|
||||
j.noopProgram = prog
|
||||
}
|
||||
|
||||
// 设置 require 支持
|
||||
new(require.Registry).Enable(j.vm)
|
||||
|
||||
// 注入全局对象和函数
|
||||
j.setupGlobals()
|
||||
|
||||
// 加载用户脚本
|
||||
_, err := j.vm.RunString(j.Addition.Script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load JavaScript script: %v", err)
|
||||
}
|
||||
|
||||
// 验证 sendMessage 函数是否存在
|
||||
sendMessage := j.vm.Get("sendMessage")
|
||||
if sendMessage == nil || goja.IsUndefined(sendMessage) {
|
||||
return errors.New("sendMessage function not defined in script")
|
||||
}
|
||||
|
||||
// 验证是否可调用
|
||||
if _, ok := goja.AssertFunction(sendMessage); !ok {
|
||||
return errors.New("sendMessage is not a function")
|
||||
}
|
||||
|
||||
// sendEvent 函数是可选的,不强制要求存在
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) Destroy() error {
|
||||
if j.vm != nil {
|
||||
j.vm = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
|
||||
if j.vm == nil {
|
||||
if err := j.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 sendMessage 函数
|
||||
sendMessageFunc, ok := goja.AssertFunction(j.vm.Get("sendMessage"))
|
||||
if !ok {
|
||||
return errors.New("sendMessage is not a callable function")
|
||||
}
|
||||
|
||||
// 调用 sendMessage 函数
|
||||
resultChan := make(chan error, 1)
|
||||
timeoutChan := time.After(30 * time.Second)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := sendMessageFunc(goja.Undefined(), j.vm.ToValue(message), j.vm.ToValue(title))
|
||||
if err != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 Promise 返回值
|
||||
if promise, ok := result.Export().(*goja.Promise); ok {
|
||||
// 等待 Promise 完成
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
resultChan <- errors.New("JavaScript execution timeout")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 运行微任务以处理 Promise 回调
|
||||
j.runMicrotasks()
|
||||
|
||||
state := promise.State()
|
||||
if state == goja.PromiseStateFulfilled {
|
||||
// Promise 成功完成,检查返回值
|
||||
promiseResult := promise.Result()
|
||||
if !promiseResult.ToBoolean() {
|
||||
resultChan <- errors.New("sendMessage returned false")
|
||||
} else {
|
||||
resultChan <- nil
|
||||
}
|
||||
return
|
||||
} else if state == goja.PromiseStateRejected {
|
||||
// Promise 被拒绝
|
||||
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
|
||||
return
|
||||
}
|
||||
// state == goja.PromiseStatePending, 继续等待
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理布尔或其他返回值
|
||||
if result.ToBoolean() {
|
||||
resultChan <- nil
|
||||
} else {
|
||||
resultChan <- errors.New("sendMessage returned false")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-resultChan:
|
||||
return err
|
||||
case <-timeoutChan:
|
||||
return errors.New("JavaScript execution timeout after 30 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
|
||||
if j.vm == nil {
|
||||
if err := j.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否定义了 sendEvent 函数
|
||||
sendEventValue := j.vm.Get("sendEvent")
|
||||
if sendEventValue == nil || goja.IsUndefined(sendEventValue) {
|
||||
// 如果没有定义 sendEvent,则回退到使用 SendTextMessage
|
||||
return j.fallbackToTextMessage(event)
|
||||
}
|
||||
|
||||
sendEventFunc, ok := goja.AssertFunction(sendEventValue)
|
||||
if !ok {
|
||||
// 如果 sendEvent 不是函数,回退到 SendTextMessage
|
||||
return j.fallbackToTextMessage(event)
|
||||
}
|
||||
|
||||
// 将 EventMessage 转换为 JavaScript 对象
|
||||
eventJSON, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %v", err)
|
||||
}
|
||||
|
||||
var eventMap map[string]interface{}
|
||||
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal event: %v", err)
|
||||
}
|
||||
|
||||
// 调用 sendEvent 函数
|
||||
resultChan := make(chan error, 1)
|
||||
timeoutChan := time.After(30 * time.Second)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := sendEventFunc(goja.Undefined(), j.vm.ToValue(eventMap))
|
||||
if err != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 Promise 返回值
|
||||
if promise, ok := result.Export().(*goja.Promise); ok {
|
||||
// 等待 Promise 完成
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
resultChan <- errors.New("JavaScript execution timeout")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 运行微任务以处理 Promise 回调
|
||||
j.runMicrotasks()
|
||||
|
||||
state := promise.State()
|
||||
if state == goja.PromiseStateFulfilled {
|
||||
// Promise 成功完成,检查返回值
|
||||
promiseResult := promise.Result()
|
||||
if !promiseResult.ToBoolean() {
|
||||
resultChan <- errors.New("sendEvent returned false")
|
||||
} else {
|
||||
resultChan <- nil
|
||||
}
|
||||
return
|
||||
} else if state == goja.PromiseStateRejected {
|
||||
// Promise 被拒绝
|
||||
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
|
||||
return
|
||||
}
|
||||
// state == goja.PromiseStatePending, 继续等待
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理布尔或其他返回值
|
||||
if result.ToBoolean() {
|
||||
resultChan <- nil
|
||||
} else {
|
||||
resultChan <- errors.New("sendEvent returned false")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-resultChan:
|
||||
return err
|
||||
case <-timeoutChan:
|
||||
return errors.New("JavaScript execution timeout after 30 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackToTextMessage 当没有定义 sendEvent 时,回退到使用文本消息格式
|
||||
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
|
||||
// 构建简单的文本消息
|
||||
message := fmt.Sprintf("%s%s%s\nEvent: %s\nMessage: %s\nTime: %s",
|
||||
event.Emoji, event.Emoji, event.Emoji,
|
||||
event.Event,
|
||||
event.Message,
|
||||
event.Time.Format(time.RFC3339))
|
||||
|
||||
// 添加客户端信息
|
||||
if len(event.Clients) > 0 {
|
||||
clientNames := make([]string, 0, len(event.Clients))
|
||||
for _, c := range event.Clients {
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = c.UUID
|
||||
}
|
||||
clientNames = append(clientNames, name)
|
||||
}
|
||||
message = fmt.Sprintf("%s%s%s\nEvent: %s\nClients: %s\nMessage: %s\nTime: %s",
|
||||
event.Emoji, event.Emoji, event.Emoji,
|
||||
event.Event,
|
||||
clientNames,
|
||||
event.Message,
|
||||
event.Time.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return j.SendTextMessage(message, event.Event)
|
||||
}
|
||||
|
||||
// runMicrotasks 安全地推动 goja 的微任务队列(例如 Promise 回调)
|
||||
func (j *JavaScriptSender) runMicrotasks() {
|
||||
if j.vm == nil {
|
||||
return
|
||||
}
|
||||
if j.noopProgram != nil {
|
||||
_, _ = j.vm.RunProgram(j.noopProgram)
|
||||
return
|
||||
}
|
||||
// 兜底: 直接运行一段 no-op 代码
|
||||
_, _ = j.vm.RunString("void 0")
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) setupGlobals() {
|
||||
// 注入 console.log
|
||||
console := j.vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
var args []interface{}
|
||||
for _, arg := range call.Arguments {
|
||||
args = append(args, arg.Export())
|
||||
}
|
||||
fmt.Println(args...)
|
||||
return goja.Undefined()
|
||||
})
|
||||
console.Set("error", func(call goja.FunctionCall) goja.Value {
|
||||
fmt.Print("Error: ")
|
||||
for i, arg := range call.Arguments {
|
||||
if i > 0 {
|
||||
fmt.Print(" ")
|
||||
}
|
||||
fmt.Print(arg.Export())
|
||||
}
|
||||
fmt.Println()
|
||||
return goja.Undefined()
|
||||
})
|
||||
j.vm.Set("console", console)
|
||||
|
||||
// 注入 fetch API
|
||||
j.vm.Set("fetch", j.createFetchFunction())
|
||||
|
||||
// 注入 XMLHttpRequest (xhr)
|
||||
j.vm.Set("XMLHttpRequest", j.createXHRConstructor())
|
||||
|
||||
// 注入 setTimeout
|
||||
j.vm.Set("setTimeout", func(call goja.FunctionCall) goja.Value {
|
||||
callback := call.Argument(0)
|
||||
delay := call.Argument(1).ToInteger()
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
if fn, ok := goja.AssertFunction(callback); ok {
|
||||
fn(goja.Undefined())
|
||||
}
|
||||
}()
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// 注入 Promise 构造函数
|
||||
j.vm.RunString(`
|
||||
if (typeof Promise === 'undefined') {
|
||||
// Promise polyfill 会由 goja 自动提供
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &JavaScriptSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
|
||||
@@ -0,0 +1,5 @@
|
||||
package javascript
|
||||
|
||||
type Addition struct {
|
||||
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
@@ -50,21 +52,21 @@ func Initialize() {
|
||||
}
|
||||
})
|
||||
}()
|
||||
cfg, _ := config.Get()
|
||||
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
|
||||
|
||||
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
|
||||
if NotificationMethod == "" || NotificationMethod == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试从数据库加载配置
|
||||
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
|
||||
senderConfig, err := database.GetMessageSenderConfigByName(NotificationMethod)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
|
||||
LoadProvider(NotificationMethod, senderConfig.Addition)
|
||||
}
|
||||
|
||||
func SendTextMessage(message string, title string) error {
|
||||
@@ -72,11 +74,11 @@ func SendTextMessage(message string, title string) error {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.Get()
|
||||
NotificationEnabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.NotificationEnabled {
|
||||
if !NotificationEnabled {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -89,3 +91,75 @@ func SendTextMessage(message string, title string) error {
|
||||
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
|
||||
return err
|
||||
}
|
||||
func SendEvent(event models.EventMessage) error {
|
||||
if CurrentProvider() == nil {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.NotificationEnabledKey: false,
|
||||
config.NotificationTemplateKey: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg[config.NotificationEnabledKey].(bool) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查提供者是否实现了 IEventMessageSender 接口
|
||||
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
|
||||
// 如果实现了,直接调用 SendEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
err = eventSender.SendEvent(event)
|
||||
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
|
||||
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果没有实现,使用模板格式化为文本消息
|
||||
messageTemplate := cfg[config.NotificationTemplateKey].(string)
|
||||
|
||||
messageTemplate = parseTemplate(messageTemplate, event)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
err = CurrentProvider().SendTextMessage(messageTemplate, event.Event)
|
||||
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" { // QQ 会返回这个错误,但实际上消息是发送成功的
|
||||
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTemplate(messageTemplate string, event models.EventMessage) string {
|
||||
// Aggregate client names. If Name is empty, fall back to UUID.
|
||||
clientNames := make([]string, 0, len(event.Clients))
|
||||
for _, c := range event.Clients {
|
||||
name := c.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
// fallback to UUID when name is not set
|
||||
name = c.UUID
|
||||
}
|
||||
clientNames = append(clientNames, name)
|
||||
}
|
||||
joinedClients := strings.Join(clientNames, ", ")
|
||||
|
||||
replaceMap := map[string]string{
|
||||
"{{event}}": event.Event,
|
||||
"{{client}}": joinedClients,
|
||||
"{{time}}": event.Time.Format(time.RFC3339),
|
||||
"{{message}}": event.Message,
|
||||
"{{emoji}}": event.Emoji,
|
||||
}
|
||||
result := messageTemplate
|
||||
for placeholder, value := range replaceMap {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package serverchan3
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// Addition 为 Server酱³ 推送通道的配置项
|
||||
// 所有字段通过管理页面以 JSON 形式进行设置
|
||||
type Addition struct {
|
||||
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
|
||||
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
|
||||
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
|
||||
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
|
||||
}
|
||||
|
||||
// 注册 Server酱³ 推送通道到工厂
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChan3Sender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package serverchan3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// ServerChan3Sender 为 Server酱³ 推送实现
|
||||
type ServerChan3Sender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// GetName 返回推送通道名称
|
||||
func (s *ServerChan3Sender) GetName() string {
|
||||
return "Server酱³"
|
||||
}
|
||||
|
||||
// GetConfiguration 返回配置结构体指针
|
||||
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
|
||||
return &s.Addition
|
||||
}
|
||||
|
||||
// Init 初始化(当前无需处理)
|
||||
func (s *ServerChan3Sender) Init() error { return nil }
|
||||
|
||||
// Destroy 清理(当前无需处理)
|
||||
func (s *ServerChan3Sender) Destroy() error { return nil }
|
||||
|
||||
// SendTextMessage 发送文本消息(携带标题)。
|
||||
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
|
||||
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
|
||||
apiURL := strings.TrimSpace(s.Addition.APIURL)
|
||||
if apiURL == "" {
|
||||
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
|
||||
}
|
||||
|
||||
// 简化校验:若标题与正文均为空则拒绝发送
|
||||
finalTitle := strings.TrimSpace(title)
|
||||
finalMessage := strings.TrimSpace(message)
|
||||
if finalTitle == "" && finalMessage == "" {
|
||||
return fmt.Errorf("serverchan3: 标题与正文均为空")
|
||||
}
|
||||
|
||||
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
|
||||
payload := map[string]string{
|
||||
"title": finalTitle,
|
||||
"desp": finalMessage,
|
||||
}
|
||||
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
|
||||
parts := strings.Split(tagsStr, "|")
|
||||
var cleaned []string
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t != "" {
|
||||
cleaned = append(cleaned, t)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
|
||||
payload["tags"] = strings.Join(cleaned, "|")
|
||||
}
|
||||
}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("组装 JSON 失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
// 固定设置 Content-Type
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// 读取响应体用于错误信息打印
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
msg := strings.TrimSpace(string(b))
|
||||
if msg == "" {
|
||||
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
|
||||
|
||||
// 确保实现 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
|
||||
@@ -0,0 +1,25 @@
|
||||
package serverchanturbo
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// Addition 为 Server酱 Turbo 推送通道的配置项
|
||||
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
|
||||
type Addition struct {
|
||||
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
|
||||
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
|
||||
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
|
||||
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
|
||||
// NoIP 是否隐藏调用 IP,填 1 则隐藏
|
||||
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
|
||||
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
|
||||
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
|
||||
}
|
||||
|
||||
// 注册 Server酱 Turbo 推送通道到工厂
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChanTurboSender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package serverchanturbo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
|
||||
type ServerChanTurboSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// GetName 返回推送通道名称
|
||||
func (s *ServerChanTurboSender) GetName() string {
|
||||
return "Server酱Turbo"
|
||||
}
|
||||
|
||||
// GetConfiguration 返回配置结构体指针
|
||||
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
|
||||
return &s.Addition
|
||||
}
|
||||
|
||||
// Init 初始化(当前无需处理)
|
||||
func (s *ServerChanTurboSender) Init() error { return nil }
|
||||
|
||||
// Destroy 清理(当前无需处理)
|
||||
func (s *ServerChanTurboSender) Destroy() error { return nil }
|
||||
|
||||
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
|
||||
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
|
||||
apiURL := strings.TrimSpace(s.Addition.APIURL)
|
||||
if apiURL == "" {
|
||||
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
|
||||
}
|
||||
|
||||
finalTitle := strings.TrimSpace(title)
|
||||
finalMessage := strings.TrimSpace(message)
|
||||
if finalTitle == "" && finalMessage == "" {
|
||||
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": finalTitle,
|
||||
"desp": finalMessage,
|
||||
}
|
||||
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
|
||||
payload["channel"] = ch
|
||||
}
|
||||
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
|
||||
payload["noip"] = "1"
|
||||
}
|
||||
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
|
||||
payload["openid"] = openid
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
|
||||
}
|
||||
// 使用 JSON 传递参数,需要设置 Content-Type
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
msg := strings.TrimSpace(string(b))
|
||||
if msg == "" {
|
||||
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确保实现 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
|
||||
@@ -93,14 +93,19 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
|
||||
}
|
||||
|
||||
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
|
||||
// 替换模板中的占位符
|
||||
body := w.replaceTemplate(w.Addition.Body, message, title)
|
||||
|
||||
contentType := w.Addition.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
// 用户自定义模板,按 Content-Type 决定如何替换占位符
|
||||
var body string
|
||||
if isJSONContentType(contentType) {
|
||||
body = w.replaceTemplateJSON(w.Addition.Body, message, title)
|
||||
} else {
|
||||
body = w.replaceTemplate(w.Addition.Body, message, title)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,10 +130,56 @@ func (w *WebhookSender) createGETRequest(message, title string) (*http.Request,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符
|
||||
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符(不做转义)
|
||||
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{message}}", message)
|
||||
result = strings.ReplaceAll(result, "{{title}}", title)
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceTemplateJSON 在 JSON 场景下替换占位符,使用 \uXXXX 形式进行转义(尤其换行/控制字符)
|
||||
func (w *WebhookSender) replaceTemplateJSON(template, message, title string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{message}}", jsonUnicodeEscapeString(message))
|
||||
result = strings.ReplaceAll(result, "{{title}}", jsonUnicodeEscapeString(title))
|
||||
return result
|
||||
}
|
||||
|
||||
// isJSONContentType 判断是否为 JSON 内容类型
|
||||
func isJSONContentType(ct string) bool {
|
||||
return strings.Contains(strings.ToLower(ct), "application/json")
|
||||
}
|
||||
|
||||
// jsonUnicodeEscapeString 将字符串按 JSON 规则进行转义,并尽量使用 \uXXXX 形式(尤其控制字符、引号和反斜杠)
|
||||
// 注意:该结果适合放入 JSON 字符串字面量的引号内部。
|
||||
func jsonUnicodeEscapeString(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString("\\u0022")
|
||||
case '\\':
|
||||
b.WriteString("\\u005C")
|
||||
case '\b':
|
||||
b.WriteString("\\u0008")
|
||||
case '\f':
|
||||
b.WriteString("\\u000C")
|
||||
case '\n':
|
||||
b.WriteString("\\u000A")
|
||||
case '\r':
|
||||
b.WriteString("\\u000D")
|
||||
case '\t':
|
||||
b.WriteString("\\u0009")
|
||||
default:
|
||||
if r < 0x20 {
|
||||
// 其他控制字符
|
||||
b.WriteString(fmt.Sprintf("\\u%04X", r))
|
||||
} else {
|
||||
// 其余字符按原样输出(UTF-8),JSON 允许非 ASCII 字符
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/renewal"
|
||||
)
|
||||
@@ -21,7 +23,10 @@ func CheckExpireScheduledWork() {
|
||||
duration := next.Sub(now)
|
||||
time.Sleep(duration)
|
||||
|
||||
cfg, err := config.Get()
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.ExpireNotificationEnabledKey: false,
|
||||
config.ExpireNotificationLeadDaysKey: 7,
|
||||
})
|
||||
if err != nil {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
@@ -36,8 +41,8 @@ func CheckExpireScheduledWork() {
|
||||
checkTime := time.Now()
|
||||
|
||||
// 过期提醒检查(仅当启用过期通知时)
|
||||
if cfg.ExpireNotificationEnabled {
|
||||
notificationLeadDays := cfg.ExpireNotificationLeadDays
|
||||
if cfg[config.ExpireNotificationEnabledKey].(bool) {
|
||||
notificationLeadDays := cfg[config.ExpireNotificationLeadDaysKey].(int)
|
||||
|
||||
type clientToExpireInfo struct {
|
||||
Name string
|
||||
@@ -67,11 +72,16 @@ func CheckExpireScheduledWork() {
|
||||
}
|
||||
|
||||
if len(clientLeadToExpire) > 0 {
|
||||
message := "The following clients are about to expire: \n\n"
|
||||
message := ""
|
||||
for _, clientInfo := range clientLeadToExpire {
|
||||
message += fmt.Sprintf("• %s in %d days\n", clientInfo.Name, clientInfo.DaysLeft)
|
||||
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
|
||||
}
|
||||
messageSender.SendTextMessage(message, "Komari Expiration Notification")
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Expire,
|
||||
Time: time.Now(),
|
||||
Message: message,
|
||||
Emoji: "⏳",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-14
@@ -1,7 +1,6 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
@@ -140,22 +140,31 @@ func checkMetricThreshold(records []models.Record, task models.LoadNotification)
|
||||
|
||||
// getMetricValue 根据指标名称获取记录中的对应值
|
||||
func getMetricValue(record models.Record, metric string) float32 {
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
switch metric {
|
||||
case "cpu":
|
||||
return record.Cpu
|
||||
case "gpu":
|
||||
return record.Gpu
|
||||
case "net_in", "netin":
|
||||
return bytesPerSecondToMbps(record.NetIn)
|
||||
case "net_out", "netout":
|
||||
return bytesPerSecondToMbps(record.NetOut)
|
||||
case "ram":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.RamTotal > 0 {
|
||||
return float32(record.Ram) / float32(client.MemTotal) * 100
|
||||
}
|
||||
return 0
|
||||
case "swap":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.SwapTotal > 0 {
|
||||
return float32(record.Swap) / float32(client.SwapTotal) * 100
|
||||
}
|
||||
@@ -165,6 +174,11 @@ func getMetricValue(record models.Record, metric string) float32 {
|
||||
case "temp":
|
||||
return record.Temp
|
||||
case "disk":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.DiskTotal > 0 {
|
||||
return float32(record.Disk) / float32(client.DiskTotal) * 100
|
||||
}
|
||||
@@ -187,22 +201,35 @@ func getMetricValue(record models.Record, metric string) float32 {
|
||||
}
|
||||
}
|
||||
|
||||
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
|
||||
if bytesPerSecond <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
|
||||
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
|
||||
}
|
||||
|
||||
// sendLoadNotification 发送负载通知
|
||||
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
|
||||
message := "The following clients have exceeded the load threshold:\n\n"
|
||||
ex_clients := []models.Client{}
|
||||
for _, clientUUID := range clientUUIDs {
|
||||
client, err := clients.GetClientByUUID(clientUUID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", clientUUID, err)
|
||||
continue
|
||||
cl, err := clients.GetClientByUUID(clientUUID)
|
||||
if err == nil {
|
||||
ex_clients = append(ex_clients, cl)
|
||||
}
|
||||
message += fmt.Sprintf("%s\n", client.Name)
|
||||
}
|
||||
if len(clientUUIDs) == 0 {
|
||||
if len(ex_clients) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
messageSender.SendTextMessage(message, fmt.Sprintf("%s - Komari Load Alert", task.Name))
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Alert,
|
||||
Clients: ex_clients,
|
||||
Time: time.Now(),
|
||||
Emoji: "⚠️",
|
||||
Message: task.Name,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/renewal"
|
||||
)
|
||||
@@ -31,8 +32,8 @@ var clientStates sync.Map
|
||||
// getNotificationConfig 获取指定客户端的通知配置。
|
||||
// 返回配置对象和一个布尔值,指示全局和该客户端是否启用通知。
|
||||
func getNotificationConfig(clientID string) (*models.OfflineNotification, bool) {
|
||||
conf, err := config.Get()
|
||||
if err != nil || !conf.NotificationEnabled {
|
||||
conf, err := config.GetAs[bool](config.NotificationEnabledKey, false)
|
||||
if err != nil || !conf {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ func OfflineNotification(clientID string, endedConnectionID int64) {
|
||||
// 若为零值,说明客户端已重连。
|
||||
// 当前的 connectionID 是否还是我们触发离线时的那个ID。如果不是,说明客户端重连过,本次离线通知已失效。
|
||||
if state.pendingOfflineSince.IsZero() || state.connectionID != expectedConnectionID {
|
||||
log.Println("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
|
||||
log.Printf("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,7 +108,13 @@ func OfflineNotification(clientID string, endedConnectionID int64) {
|
||||
// Send notification
|
||||
message := fmt.Sprintf("🔴%s is offline", client.Name)
|
||||
go func(msg string) {
|
||||
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
|
||||
if err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Offline,
|
||||
Clients: []models.Client{client},
|
||||
Time: time.Now(),
|
||||
//Message: msg,
|
||||
Emoji: "🔴",
|
||||
}); err != nil {
|
||||
log.Println("Failed to send offline notification:", err)
|
||||
}
|
||||
}(message)
|
||||
@@ -161,7 +168,7 @@ func OnlineNotification(clientID string, connectionID int64) {
|
||||
// 规则3: 没断开后重连, 不通知
|
||||
// 为了解决OfflineNotify中不是全程加锁
|
||||
if state.isConnExist {
|
||||
log.Println("%s has connection exist: %d", clientID, connectionID)
|
||||
log.Printf("%s has connection exist: %d", clientID, connectionID)
|
||||
return
|
||||
} else {
|
||||
state.isConnExist = true
|
||||
@@ -170,7 +177,13 @@ func OnlineNotification(clientID string, connectionID int64) {
|
||||
// 规则4:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
|
||||
message := fmt.Sprintf("🟢%s is online", client.Name)
|
||||
go func(msg string) {
|
||||
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
|
||||
if err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Online,
|
||||
Clients: []models.Client{client},
|
||||
Time: time.Now(),
|
||||
//Message: msg,
|
||||
Emoji: "🟢",
|
||||
}); err != nil {
|
||||
log.Println("Failed to send online notification:", err)
|
||||
}
|
||||
}(message)
|
||||
|
||||
@@ -2,12 +2,14 @@ package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
@@ -25,18 +27,17 @@ func CheckTraffic() {
|
||||
if len(reports) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Get()
|
||||
cfg, err := config.GetAs[float64](config.TrafficLimitPercentageKey, 80.0)
|
||||
if err != nil {
|
||||
return
|
||||
slog.Error("failed to get traffic limit percentage", "error", err)
|
||||
}
|
||||
|
||||
if cfg.TrafficLimitPercentage <= 0 {
|
||||
if cfg <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
|
||||
startThreshold := cfg.TrafficLimitPercentage
|
||||
startThreshold := cfg
|
||||
if startThreshold < 0 {
|
||||
startThreshold = 0
|
||||
}
|
||||
@@ -87,10 +88,15 @@ func CheckTraffic() {
|
||||
if curStep > lastStep { // 只在进入新步进时提醒一次
|
||||
trafficCache.SetDefault(key, curStep)
|
||||
|
||||
title := "Komari Traffic Alert"
|
||||
msg := fmt.Sprintf("%s traffic used %d%% (%s / %s), type=%s", c.Name, curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
|
||||
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
|
||||
// 发送通知(内部会检查 NotificationEnabled)
|
||||
_ = messageSender.SendTextMessage(msg, title)
|
||||
_ = messageSender.SendEvent(models.EventMessage{
|
||||
Event: "Traffic",
|
||||
Clients: []models.Client{c},
|
||||
Time: time.Now(),
|
||||
Emoji: "⚠️",
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (g *Generic) OnCallback(ctx *gin.Context, state string, query map[string]st
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -79,7 +79,7 @@ func (g *Generic) OnCallback(ctx *gin.Context, state string, query map[string]st
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
|
||||
@@ -71,7 +71,7 @@ func (g *Github) OnCallback(ctx *gin.Context, state string, query map[string]str
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -82,7 +82,7 @@ func (g *Github) OnCallback(ctx *gin.Context, state string, query map[string]str
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
)
|
||||
@@ -70,15 +70,15 @@ func Initialize() error {
|
||||
}
|
||||
}
|
||||
})
|
||||
cfg, _ := config.Get()
|
||||
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
cfg, _ := config.GetAs[string](config.OAuthProviderKey, "github")
|
||||
if cfg == "" || cfg == "none" {
|
||||
LoadProvider("github", "{}")
|
||||
return nil
|
||||
}
|
||||
provider, err := database.GetOidcConfigByName(cfg.OAuthProvider)
|
||||
provider, err := database.GetOidcConfigByName(cfg)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
// 如果没有找到配置,使用github provider
|
||||
LoadProvider("github", "{}")
|
||||
return nil
|
||||
}
|
||||
err = LoadProvider(provider.Name, provider.Addition)
|
||||
|
||||
+24
-10
@@ -1,19 +1,33 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"math/rand"
|
||||
"time"
|
||||
"crypto/rand"
|
||||
)
|
||||
|
||||
func GenerateRandomString(length int) string {
|
||||
b := make([]byte, length)
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
_, err := r.Read(b)
|
||||
if err != nil {
|
||||
if length <= 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:length]
|
||||
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const n = byte(len(charset)) // 62
|
||||
const threshold = byte(256 - (256 % len(charset))) // 248
|
||||
|
||||
out := make([]byte, 0, length)
|
||||
buf := make([]byte, length)
|
||||
for len(out) < length {
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, b := range buf {
|
||||
if b < threshold {
|
||||
out = append(out, charset[int(b%n)])
|
||||
if len(out) == length {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func GeneratePassword() string {
|
||||
@@ -21,5 +35,5 @@ func GeneratePassword() string {
|
||||
}
|
||||
|
||||
func GenerateToken() string {
|
||||
return GenerateRandomString(16)
|
||||
}
|
||||
return GenerateRandomString(22)
|
||||
}
|
||||
+31
-16
@@ -7,17 +7,18 @@ import (
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func CheckAndAutoRenewal(client models.Client) {
|
||||
// 自动续费检查
|
||||
type renewedClient struct {
|
||||
Name string
|
||||
NewExpireTime time.Time
|
||||
}
|
||||
var renewedClients []renewedClient
|
||||
//type renewedClient struct {
|
||||
// Name string
|
||||
// NewExpireTime time.Time
|
||||
//}
|
||||
//var renewedClients []renewedClient
|
||||
|
||||
if !client.AutoRenewal {
|
||||
return
|
||||
@@ -96,22 +97,36 @@ func CheckAndAutoRenewal(client models.Client) {
|
||||
return
|
||||
}
|
||||
|
||||
renewedClients = append(renewedClients, renewedClient{
|
||||
Name: client.Name,
|
||||
NewExpireTime: newExpireTime,
|
||||
})
|
||||
//renewedClients = append(renewedClients, renewedClient{
|
||||
// Name: client.Name,
|
||||
// NewExpireTime: newExpireTime,
|
||||
//})
|
||||
|
||||
auditlog.EventLog("renewal", fmt.Sprintf("Auto-renewed client: %s until %s",
|
||||
client.Name, newExpireTime.Format("2006-01-02")))
|
||||
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Renew,
|
||||
Clients: []models.Client{client},
|
||||
Time: time.Now(),
|
||||
Emoji: "🔄",
|
||||
Message: fmt.Sprintf("• %s until %s\n", client.Name, newExpireTime.Format("2006-01-02")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送续费通知
|
||||
if len(renewedClients) > 0 {
|
||||
message := "The following clients have been automatically renewed: \n\n"
|
||||
for _, clientInfo := range renewedClients {
|
||||
message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
|
||||
}
|
||||
messageSender.SendTextMessage(message, "Komari Auto-Renewal Notification")
|
||||
}
|
||||
// if len(renewedClients) > 0 {
|
||||
// message := ""
|
||||
// for _, clientInfo := range renewedClients {
|
||||
// message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
|
||||
// }
|
||||
// messageSender.SendEvent(models.EventMessage{
|
||||
// Event: messageevent.Renew,
|
||||
// Clients: []models.Client{client},
|
||||
// Time: time.Now(),
|
||||
// Emoji: "🔄",
|
||||
// Message: message,
|
||||
// })
|
||||
// }
|
||||
}
|
||||
+292
-2
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
@@ -48,13 +49,19 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
}
|
||||
}
|
||||
|
||||
var sumCPU, sumLOAD float32
|
||||
var sumCPU, sumLOAD, sumGPU float32
|
||||
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal, sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
|
||||
var sumPROCESS, sumConnections, sumConnectionsUDP int
|
||||
|
||||
if topPercentage > 0 && topPercentage <= 1 {
|
||||
sumCPU, _ = sumAndSort(func(r common.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
|
||||
sumLOAD, _ = sumAndSort(func(r common.Report) float32 { return float32(r.Load.Load1) }, nil, true)
|
||||
sumGPU, _ = sumAndSort(func(r common.Report) float32 {
|
||||
if r.GPU != nil {
|
||||
return float32(r.GPU.AverageUsage)
|
||||
}
|
||||
return 0
|
||||
}, nil, true)
|
||||
|
||||
_, sumRAM = sumAndSort(nil, func(r common.Report) int64 { return r.Ram.Used }, false)
|
||||
//_, sumRAMTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Ram.Total }, false)
|
||||
@@ -78,6 +85,9 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
for _, r := range records {
|
||||
sumCPU += float32(r.CPU.Usage)
|
||||
sumLOAD += float32(r.Load.Load1)
|
||||
if r.GPU != nil {
|
||||
sumGPU += float32(r.GPU.AverageUsage)
|
||||
}
|
||||
sumRAM += r.Ram.Used
|
||||
sumRAMTotal += r.Ram.Total
|
||||
sumSWAP += r.Swap.Used
|
||||
@@ -99,7 +109,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
Client: uuid,
|
||||
Time: models.FromTime(time),
|
||||
Cpu: sumCPU / float32(recordsToAverageCount),
|
||||
Gpu: 0, // 保持原始行为
|
||||
Gpu: sumGPU / float32(recordsToAverageCount), // 计算GPU平均使用率
|
||||
Ram: sumRAM / int64(recordsToAverageCount),
|
||||
RamTotal: records[0].Ram.Total,
|
||||
Swap: sumSWAP / int64(recordsToAverageCount),
|
||||
@@ -118,3 +128,283 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
}
|
||||
return newRecord
|
||||
}
|
||||
|
||||
// AverageGPUReports 使用与 AverageReport 相同的聚合逻辑处理GPU数据
|
||||
// 返回每个GPU设备的聚合记录
|
||||
func AverageGPUReports(uuid string, time time.Time, reports []common.Report, topPercentage float64) []models.GPURecord {
|
||||
if len(reports) == 0 {
|
||||
return []models.GPURecord{}
|
||||
}
|
||||
|
||||
// 收集所有GPU设备数据
|
||||
deviceData := make(map[int]struct {
|
||||
DeviceName string
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float64
|
||||
Temperature []int
|
||||
})
|
||||
|
||||
for _, report := range reports {
|
||||
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
|
||||
for idx, gpu := range report.GPU.DetailedInfo {
|
||||
if _, exists := deviceData[idx]; !exists {
|
||||
deviceData[idx] = struct {
|
||||
DeviceName string
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float64
|
||||
Temperature []int
|
||||
}{DeviceName: gpu.Name}
|
||||
}
|
||||
data := deviceData[idx]
|
||||
data.MemTotal = append(data.MemTotal, gpu.MemoryTotal)
|
||||
data.MemUsed = append(data.MemUsed, gpu.MemoryUsed)
|
||||
data.Utilization = append(data.Utilization, gpu.Utilization)
|
||||
data.Temperature = append(data.Temperature, gpu.Temperature)
|
||||
deviceData[idx] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复用现有的聚合逻辑
|
||||
sumAndSort := func(values []float64, topPerc float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
count := len(values)
|
||||
recordsToAverageCount := count
|
||||
if topPerc > 0 && topPerc <= 1 {
|
||||
recordsToAverageCount = int(float64(count) * topPerc)
|
||||
if recordsToAverageCount == 0 && count > 0 {
|
||||
recordsToAverageCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
if topPerc > 0 && topPerc <= 1 {
|
||||
sort.Float64s(values)
|
||||
// 取最高的值
|
||||
var sum float64
|
||||
for i := count - recordsToAverageCount; i < count; i++ {
|
||||
sum += values[i]
|
||||
}
|
||||
return sum / float64(recordsToAverageCount)
|
||||
} else {
|
||||
var sum float64
|
||||
for _, val := range values {
|
||||
sum += val
|
||||
}
|
||||
return sum / float64(count)
|
||||
}
|
||||
}
|
||||
|
||||
sumAndSortInt64 := func(values []int64, topPerc float64) int64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
count := len(values)
|
||||
recordsToAverageCount := count
|
||||
if topPerc > 0 && topPerc <= 1 {
|
||||
recordsToAverageCount = int(float64(count) * topPerc)
|
||||
if recordsToAverageCount == 0 && count > 0 {
|
||||
recordsToAverageCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
if topPerc > 0 && topPerc <= 1 {
|
||||
sort.Slice(values, func(i, j int) bool { return values[i] > values[j] })
|
||||
var sum int64
|
||||
for i := 0; i < recordsToAverageCount; i++ {
|
||||
sum += values[i]
|
||||
}
|
||||
return sum / int64(recordsToAverageCount)
|
||||
} else {
|
||||
var sum int64
|
||||
for _, val := range values {
|
||||
sum += val
|
||||
}
|
||||
return sum / int64(count)
|
||||
}
|
||||
}
|
||||
|
||||
sumAndSortInt := func(values []int, topPerc float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
int64Values := make([]int64, len(values))
|
||||
for i, v := range values {
|
||||
int64Values[i] = int64(v)
|
||||
}
|
||||
return int(sumAndSortInt64(int64Values, topPerc))
|
||||
}
|
||||
|
||||
// 生成每个设备的聚合记录
|
||||
var result []models.GPURecord
|
||||
for deviceIndex, data := range deviceData {
|
||||
if len(data.MemTotal) > 0 {
|
||||
record := models.GPURecord{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(time),
|
||||
DeviceIndex: deviceIndex,
|
||||
DeviceName: data.DeviceName,
|
||||
MemTotal: sumAndSortInt64(data.MemTotal, topPercentage),
|
||||
MemUsed: sumAndSortInt64(data.MemUsed, topPercentage),
|
||||
Utilization: float32(sumAndSort(data.Utilization, topPercentage)),
|
||||
Temperature: sumAndSortInt(data.Temperature, topPercentage),
|
||||
}
|
||||
result = append(result, record)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func DataMasking(str string, private []string) string {
|
||||
if str == "" || len(private) == 0 {
|
||||
return str
|
||||
}
|
||||
mask := "********"
|
||||
|
||||
// 相似度阈值,可根据需要调节(0~1,越大越严格)
|
||||
const threshold = 0.8
|
||||
|
||||
runes := []rune(str)
|
||||
n := len(runes)
|
||||
toMask := make([]bool, n)
|
||||
|
||||
// 预处理 private 中的词,去掉空、重复
|
||||
uniq := make(map[string]struct{})
|
||||
var words []string
|
||||
for _, w := range private {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := uniq[w]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[w] = struct{}{}
|
||||
words = append(words, w)
|
||||
}
|
||||
if len(words) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
// 逐词进行滑动窗口匹配 + 模糊匹配(Levenshtein 相似度)
|
||||
for _, w := range words {
|
||||
wRunes := []rune(w)
|
||||
wl := len(wRunes)
|
||||
if wl == 0 || wl > n {
|
||||
continue
|
||||
}
|
||||
|
||||
// 滑动窗口大小采用敏感词长度
|
||||
for i := 0; i <= n-wl; i++ {
|
||||
if allMasked(toMask[i : i+wl]) { // 已全被标记则跳过
|
||||
continue
|
||||
}
|
||||
sub := string(runes[i : i+wl])
|
||||
sim := similarity(sub, w)
|
||||
if sim >= threshold {
|
||||
for k := 0; k < wl; k++ {
|
||||
toMask[i+k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构造输出:连续的掩码段只输出一次;如果原始被遮蔽长度>5,展示首尾字符
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
for i < n {
|
||||
if toMask[i] {
|
||||
start := i
|
||||
for i < n && toMask[i] {
|
||||
i++
|
||||
}
|
||||
end := i // 不包含
|
||||
segLen := end - start
|
||||
if segLen > 5 {
|
||||
b.WriteRune(runes[start])
|
||||
b.WriteString(mask)
|
||||
b.WriteRune(runes[end-1])
|
||||
} else {
|
||||
b.WriteString(mask)
|
||||
}
|
||||
} else {
|
||||
b.WriteRune(runes[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// allMasked 判断一个区间是否全部已经被标记
|
||||
func allMasked(bools []bool) bool {
|
||||
for _, v := range bools {
|
||||
if !v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// similarity 返回两个字符串的相似度 (0~1),基于 Levenshtein 距离
|
||||
func similarity(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1
|
||||
}
|
||||
ar := []rune(a)
|
||||
br := []rune(b)
|
||||
dist := levenshtein(ar, br)
|
||||
maxLen := len(ar)
|
||||
if len(br) > maxLen {
|
||||
maxLen = len(br)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
return 1
|
||||
}
|
||||
return 1 - float64(dist)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshtein 计算两个 rune slice 的编辑距离
|
||||
func levenshtein(a, b []rune) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
// 使用滚动数组降低空间复杂度
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 0
|
||||
if a[i-1] != b[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
del := prev[j] + 1
|
||||
ins := curr[j-1] + 1
|
||||
sub := prev[j-1] + cost
|
||||
curr[j] = minInt(del, ins, sub)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func minInt(vals ...int) int {
|
||||
m := vals[0]
|
||||
for _, v := range vals[1:] {
|
||||
if v < m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
@@ -11,7 +12,13 @@ var (
|
||||
connectedClients = make(map[string]*SafeConn)
|
||||
ConnectedUsers = []*websocket.Conn{}
|
||||
latestReport = make(map[string]*common.Report)
|
||||
mu = sync.RWMutex{}
|
||||
// presenceOnly stores online state for non-WebSocket agents (e.g., Nezha gRPC)
|
||||
// value keeps connectionID and a soft expiration to avoid flicker
|
||||
presenceOnly = make(map[string]struct {
|
||||
id int64
|
||||
expire time.Time
|
||||
})
|
||||
mu = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func GetConnectedClients() map[string]*SafeConn {
|
||||
@@ -44,6 +51,57 @@ func DeleteConnectedClients(uuid string) {
|
||||
// 只从 map 中删除,不再负责关闭连接
|
||||
delete(connectedClients, uuid)
|
||||
}
|
||||
|
||||
// SetPresence sets or clears presence for non-WebSocket agents.
|
||||
// When present=false, it only clears if the connectionID matches current one.
|
||||
// KeepAlivePresence sets presence with TTL for non-WebSocket agents.
|
||||
func KeepAlivePresence(uuid string, connectionID int64, ttl time.Duration) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
presenceOnly[uuid] = struct {
|
||||
id int64
|
||||
expire time.Time
|
||||
}{id: connectionID, expire: time.Now().Add(ttl)}
|
||||
}
|
||||
|
||||
var defaultPresenceTTL = 20 * time.Second
|
||||
|
||||
// SetPresence keeps compatibility with existing callers.
|
||||
func SetPresence(uuid string, connectionID int64, present bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if present {
|
||||
presenceOnly[uuid] = struct {
|
||||
id int64
|
||||
expire time.Time
|
||||
}{id: connectionID, expire: time.Now().Add(defaultPresenceTTL)}
|
||||
return
|
||||
}
|
||||
if cur, ok := presenceOnly[uuid]; ok && cur.id == connectionID {
|
||||
delete(presenceOnly, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllOnlineUUIDs returns a de-duplicated list of online UUIDs from both WebSocket and non-WebSocket agents.
|
||||
func GetAllOnlineUUIDs() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
set := make(map[string]struct{})
|
||||
for k := range connectedClients {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
now := time.Now()
|
||||
for k, v := range presenceOnly {
|
||||
if v.expire.After(now) {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
res := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
res = append(res, k)
|
||||
}
|
||||
return res
|
||||
}
|
||||
func GetLatestReport() map[string]*common.Report {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
Reference in new issue
Block a user