Compare commits

..
55 Commits
Author SHA1 Message Date
akizon77 b62fb1f708 feat: 添加 JavaScript 消息发送器,支持 fetch 和 XMLHttpRequest API 2025-10-25 20:43:14 +08:00
github-actions[bot] 579c196643 Merge pull request #335 from komari-monitor/dev
feat: 添加 NOTICE 文件,包含版权和许可证信息
2025-10-18 09:16:09 +00:00
Akizon77 e8da6e213a feat: 添加 NOTICE 文件,包含版权和许可证信息 2025-10-18 17:15:20 +08:00
github-actions[bot] d9426e43a9 Merge pull request #333 from komari-monitor/dev
feat: 添加 EULA 接受字段到配置模型
2025-10-17 17:15:38 +00:00
Akizon77 aad0274d56 feat: 添加 EULA 接受字段到配置模型 2025-10-18 00:15:05 +08:00
github-actions[bot] d65f7ca924 Merge pull request #331 from komari-monitor/dev
fix: 重复注册Bark
2025-10-15 05:14:01 +00:00
Akizon77 a4d95640a5 fix: 重复注册Bark 2025-10-15 13:13:31 +08:00
Akizon77 adda3a2488 docs: 移除Crowdin 2025-10-15 13:09:18 +08:00
github-actions[bot] 34654c0969 Merge pull request #320 from komari-monitor/dev
fix: #319 伪随机 token 可预测
2025-10-09 11:09:44 +00:00
Akizon77 b708f9817d fix: 伪随机 token 可预测 2025-10-09 19:09:11 +08:00
github-actions[bot] 1210a1cbd9 Merge pull request #314 from komari-monitor/dev
refactor: 重写备份上传功能
2025-10-06 08:10:07 +00:00
Akizon77 24f1a673b9 refactor: 重写备份上传功能 2025-10-06 16:07:36 +08:00
github-actions[bot] a3162e1992 Merge pull request #313 from komari-monitor/dev
feat: 添加配置项以控制是否向访客发送 IP 地址
2025-10-06 07:31:40 +00:00
Akizon77 17f904e4ab feat: 添加配置项以控制是否向访客发送 IP 地址 2025-10-06 13:52:12 +08:00
github-actions[bot] 1324f4fa21 Merge pull request #312 from komari-monitor/dev
refactor: 重写下载备份功能
2025-10-06 05:06:40 +00:00
Akizon77 cf15144bb9 refactor: 重写下载备份功能 2025-10-06 13:06:01 +08:00
github-actions[bot] 946b27b3b8 Merge pull request #309 from komari-monitor/dev
feat: 启用SQLite的WAL模式
2025-10-03 06:12:48 +00:00
Akizon77 aed5254cf0 feat: 启用SQLite的WAL模式 2025-10-03 14:11:47 +08:00
github-actions[bot] 7b8922b7ac Merge pull request #296 from komari-monitor/dev
feat: 添加自定义脚本连接域名配置
2025-09-26 13:58:45 +00:00
Akizon77 733ad4c6c0 feat: 添加自定义脚本连接域名配置 2025-09-26 21:58:01 +08:00
github-actions[bot] ef98ddd1d3 Merge pull request #295 from komari-monitor/dev
fix: 优化默认通知模板
2025-09-26 13:55:08 +00:00
Akizon77 ca28c40aca fix: 优化默认通知模板 2025-09-26 21:54:34 +08:00
github-actions[bot] 0af5903bfe Merge pull request #283 from komari-monitor/dev
fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义
2025-09-21 14:09:52 +00:00
akizon77 717b7b4a88 fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义 2025-09-21 22:08:54 +08:00
github-actions[bot] 05eec9515b Merge pull request #282 from komari-monitor/dev
fix: 处理QQ发送消息时的短响应错误
2025-09-21 13:42:07 +00:00
akizon77 877852fc29 fix: 处理QQ发送消息时的短响应错误 2025-09-21 21:38:00 +08:00
github-actions[bot] dbb9075a52 Merge pull request #281 from komari-monitor/dev
fix: 实现按任务ID分组的记录比例下采样
2025-09-20 16:43:49 +00:00
akizon77 eafcf7fe76 fix: 实现按任务ID分组的记录比例下采样 2025-09-21 00:43:00 +08:00
github-actions[bot] 094ce6c1ba Merge pull request #280 from komari-monitor/dev
feat: 更新通知机制,使用事件消息替代文本消息
2025-09-20 11:41:25 +00:00
akizon77 2eed2cf7ba fix: 测试消息使用事件 2025-09-20 17:37:22 +08:00
akizon77 88b9d3bdac Merge branch 'main' into dev 2025-09-20 17:22:29 +08:00
Akizon 9c87227613 Merge pull request #269 from kdwycz/dev
新增了GPU监控数据接收存储
2025-09-17 16:53:46 +08:00
Akizon77 bc737b1ef9 feat: 更新通知模板,添加表情和消息内容 2025-09-17 13:50:19 +08:00
Akizon77 911d990690 feat: 更新通知机制,使用事件消息替代文本消息 2025-09-17 13:41:17 +08:00
kdwycz 0f7cd5f438 新增了GPU监控数据接收存储
接收agent发送的gpu监控数据.
参考Record实现, 新增GPURecord表存储监控数据和压缩监控数据.
2025-09-15 20:23:16 +08:00
github-actions[bot] 354903b97b Merge pull request #264 from komari-monitor/dev
fix: 修正 WebSocket 升级逻辑
2025-09-14 05:06:52 +00:00
Akizon77 b432396111 fix: 修正 WebSocket 升级逻辑 2025-09-14 13:06:02 +08:00
github-actions[bot] 491c0ad531 Merge pull request #263 from komari-monitor/dev
feat: 延迟显示更多信息
2025-09-14 02:41:08 +00:00
Akizon77 1540b78487 feat: 延迟显示更多信息 2025-09-14 01:17:51 +00:00
Akizon77 11f43b116d fix: 修正Uptime 2025-09-13 20:05:46 +08:00
github-actions[bot] a0760c8e93 Merge pull request #261 from komari-monitor/dev
feat: rpc2最近信息补全延迟、uptime
2025-09-13 09:30:40 +00:00
github-actions[bot] 85ecfc3500 Merge pull request #260 from komari-monitor/dev
feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能
2025-09-13 09:29:10 +00:00
Akizon77 4c2308622a feat: rpc2最近信息补全延迟、uptime 2025-09-13 17:28:04 +08:00
Akizon77 25b6a25f9a fix: 更新 OAuthCallback 重定向路径,从 /manage 改为 /admin 2025-09-13 16:19:51 +08:00
Akizon77 7138c22d02 refactor: 移除不必要的 nezhaBoot 变量,更新 StopNezhaCompat 以强制停止服务器 2025-09-13 15:52:57 +08:00
Akizon77 a06a5c9e72 feat: 替换 StartNezhaCompatServer 为 StartNezhaCompat,标记为已弃用 2025-09-13 15:45:20 +08:00
Akizon77 668eb63668 feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能 2025-09-13 15:09:34 +08:00
Akizon77 7d75f1ba37 feat: 在客户端自动创建时记录审计日志事件 2025-09-13 15:01:20 +08:00
github-actions[bot] 2c7b59985e Merge pull request #259 from komari-monitor/dev
feat: 添加数据掩码功能以保护敏感信息
2025-09-13 06:36:17 +00:00
Akizon77 b616eeb2d4 feat: 添加数据掩码功能以保护敏感信息 2025-09-13 14:35:42 +08:00
Akizon77 0f0e359c63 wip: 通知 2025-09-12 03:01:42 +00:00
Akizon77 1b83644788 docs: 更新 Sharon 图片 2025-09-10 04:40:13 +00:00
github-actions[bot] 9a9200744e Merge pull request #250 from komari-monitor/dev
feat: 兼容哪吒Agent
2025-09-09 14:41:47 +00:00
Akizon77 e763bc12c4 docs: 添加 SharonNetworks 的赞助信息 2025-09-09 14:39:39 +00:00
Akizon77 8d7b47a7de feat: 兼容哪吒Agent 2025-09-08 13:38:47 +00:00
53 changed files with 4750 additions and 541 deletions

No files matched your search

+4
View File
@@ -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
+27
View File
@@ -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.
+16 -5
View File
@@ -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
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
+25 -1
View File
@@ -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
View File
@@ -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 自动调用
}
+5 -2
View File
@@ -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
View File
@@ -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)
}()
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 过滤记录,只返回相关字段
+2 -2
View File
@@ -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
}
+404
View File
@@ -0,0 +1,404 @@
package cmd
import (
"context"
"errors"
"io"
"log"
"math"
"net"
"strings"
"sync"
"time"
apiClient "github.com/komari-monitor/komari/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
}
+23
View File
@@ -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
+18
View File
@@ -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"`
+83
View File
@@ -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
+374
View File
@@ -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",
}
+7 -1
View File
@@ -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
+29 -2
View File
@@ -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 一致性
+12 -9
View File
@@ -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
View File
@@ -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{},
)
+15 -8
View File
@@ -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"
)
+10
View File
@@ -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"`
}
+13
View File
@@ -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
+202
View File
@@ -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
View File
@@ -107,8 +107,24 @@ sudo ./install-komari.sh
IssueやPull Requestを歓迎します!
## 謝辞
- こんなに暇でいられる自分に感謝
- PRを提出し、テーマを作成してくれたすべての開発者
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
+18 -3
View File
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
歡迎提交 Issue 或 Pull Request!
## 鳴謝
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感謝我自己能這麼閒
- 提交PR、製作主題的各位開發者
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
+18 -3
View File
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
+14 -7
View File
@@ -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
)
+52 -14
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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"
)
+3 -9
View File
@@ -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)
+151 -42
View File
@@ -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,
)
}
}
+7
View File
@@ -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
+301
View File
@@ -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)
+5
View File
@@ -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()."`
}
+73
View File
@@ -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
}
+55 -4
View File
@@ -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()
}
+10 -3
View File
@@ -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
View File
@@ -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,
})
}()
}
+17 -4
View File
@@ -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)
+9 -3
View File
@@ -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,
})
}
}
}
+2 -2
View File
@@ -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}))
}
// 获取用户信息
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+59 -1
View File
@@ -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()