mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 00:30:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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.
|
||||
@@ -100,8 +100,6 @@ sudo ./install-komari.sh
|
||||
## 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)
|
||||
|
||||
@@ -109,9 +107,22 @@ sudo ./install-komari.sh
|
||||
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
|
||||
|
||||
|
||||
+25
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+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 自动调用
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/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
|
||||
|
||||
+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)
|
||||
}()
|
||||
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
func OnRpcRequest(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
|
||||
// 如果是 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 {
|
||||
return true
|
||||
|
||||
+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/database"
|
||||
"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"
|
||||
"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)
|
||||
|
||||
cfg, _ := config.Get()
|
||||
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 cfg.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
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+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 过滤记录,只返回相关字段
|
||||
|
||||
@@ -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/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/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 cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ip := net.ParseIP(v4); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
|
||||
updates["ipv6"] = v6
|
||||
if iso == "" { // 优先使用 v4 的国家码
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ip := net.ParseIP(v6); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if iso != "" {
|
||||
// UI 使用旗帜 emoji
|
||||
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
}
|
||||
// 回写 GeoIP(包含国家码与面板启动时间)
|
||||
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -85,6 +85,16 @@ 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)
|
||||
@@ -101,6 +111,19 @@ func RunServer() {
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
messageSender.Initialize()
|
||||
}
|
||||
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
|
||||
if event.New.NezhaCompatEnabled {
|
||||
if err := StartNezhaCompat(event.New.NezhaCompatListen); 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
// 初始化 cloudflared
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
@@ -46,7 +47,12 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
if ipinfo != nil {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
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")
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
|
||||
@@ -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 一致性
|
||||
|
||||
@@ -25,15 +25,18 @@ func Get() (models.Config, error) {
|
||||
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()),
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ipinfo",
|
||||
NezhaCompatEnabled: false,
|
||||
NezhaCompatListen: "",
|
||||
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
|
||||
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)
|
||||
|
||||
+207
-1
@@ -1,10 +1,16 @@
|
||||
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"
|
||||
@@ -16,6 +22,151 @@ import (
|
||||
"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
|
||||
@@ -212,6 +363,50 @@ func GetDBInstance() *gorm.DB {
|
||||
once.Do(func() {
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
func() {
|
||||
backupZipPath := filepath.Join(".", "data", "backup.zip")
|
||||
if _, statErr := os.Stat(backupZipPath); statErr == nil {
|
||||
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
|
||||
if err := os.MkdirAll("./backup", 0755); err != nil {
|
||||
log.Printf("[restore] failed to create backup dir: %v", err)
|
||||
} else {
|
||||
tsName := time.Now().Format("20060102-150405")
|
||||
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
|
||||
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
|
||||
log.Printf("[restore] failed to zip current data: %v", zipErr)
|
||||
} else {
|
||||
log.Printf("[restore] current data zipped to %s", bakPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 删除除了 ./data/backup.zip 之外的所有文件
|
||||
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
|
||||
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
|
||||
}
|
||||
|
||||
// 6. 解压 ./data/backup.zip 到 ./data
|
||||
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
|
||||
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip extracted to ./data")
|
||||
}
|
||||
|
||||
// 7. 删除 ./data/backup.zip
|
||||
if rmErr := os.Remove(backupZipPath); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] backup.zip removed")
|
||||
}
|
||||
// 8. 删除标记
|
||||
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
|
||||
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
|
||||
} else {
|
||||
log.Printf("[restore] komari-backup-markup removed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
@@ -231,7 +426,11 @@ func GetDBInstance() *gorm.DB {
|
||||
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
|
||||
}
|
||||
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
|
||||
|
||||
instance.Exec("PRAGMA wal = ON;")
|
||||
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
}
|
||||
instance.Exec("VACUUM;")
|
||||
case "mysql":
|
||||
// MySQL 连接
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
@@ -254,6 +453,7 @@ func GetDBInstance() *gorm.DB {
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.GPURecord{},
|
||||
&models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
@@ -274,6 +474,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{},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +166,12 @@ 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)
|
||||
@@ -159,6 +214,7 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
}
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
@@ -185,6 +241,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 +336,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 +359,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
|
||||
})
|
||||
}
|
||||
+18
-2
@@ -107,8 +107,24 @@ sudo ./install-komari.sh
|
||||
IssueやPull Requestを歓迎します!
|
||||
|
||||
## 謝辞
|
||||
- こんなに暇でいられる自分に感謝
|
||||
- PRを提出し、テーマを作成してくれたすべての開発者
|
||||
|
||||
### DreamCloud
|
||||
|
||||
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
|
||||
|
||||
### 🚀 SharonNetworks スポンサー
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
|
||||
|
||||
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
|
||||
|
||||
### オープンソースコミュニティ
|
||||
|
||||
PR を送ってくれた方、テーマを作成してくれた全ての開発者
|
||||
|
||||
—— そして:こんなに暇でいられる自分に感謝
|
||||
|
||||
## Star履歴
|
||||
|
||||
|
||||
+18
-3
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
|
||||
歡迎提交 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
|
||||
|
||||
|
||||
+18
-3
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
|
||||
欢迎提交 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
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.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 +22,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,14 +35,19 @@ 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/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 // indirect
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf // 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
|
||||
@@ -59,10 +66,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.26.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
|
||||
)
|
||||
@@ -17,14 +17,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 +43,24 @@ 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.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
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-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
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=
|
||||
@@ -111,19 +131,37 @@ 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/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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
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=
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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/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"
|
||||
)
|
||||
@@ -35,6 +41,7 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("email sending is not fully configured")
|
||||
}
|
||||
|
||||
// Prepare auth
|
||||
auth := smtp.PlainAuth(
|
||||
"",
|
||||
e.Addition.Username,
|
||||
@@ -42,61 +49,163 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// Compose headers
|
||||
headers := []string{
|
||||
"To: " + strings.Join(rcptHeaderParts, ", "),
|
||||
"From: " + senderHeader,
|
||||
"Subject: " + encodedSubject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
@@ -89,3 +91,74 @@ 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.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.NotificationEnabled {
|
||||
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.NotificationTemplate
|
||||
if messageTemplate == "" {
|
||||
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -67,11 +69,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: "⏳",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -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"
|
||||
)
|
||||
@@ -189,20 +189,24 @@ func getMetricValue(record models.Record, metric string) float32 {
|
||||
|
||||
// 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,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
|
||||
+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