Compare commits

..
Author SHA1 Message Date
Akizon ca07f1b904 Merge pull request #61 from komari-monitor/dev
feat: 增加Ping任务管理功能
2025-06-22 15:05:43 +08:00
Akizon77 1aa76be031 feat: 在获取Ping记录时添加任务列表响应 2025-06-22 15:04:22 +08:00
Akizon77 38be68455c feat: 更新获取Ping记录的API路由 2025-06-22 14:56:58 +08:00
Akizon77 2f3cfd5387 fix: 修正获取4小时内记录的时间范围,增加10分钟偏移 2025-06-22 14:54:57 +08:00
Akizon77 d3a99c0ff4 feat: 获取长期记录时,4小时内的数据按15分钟分组获取最近记录 2025-06-22 14:50:08 +08:00
Akizon77 b4d5a40ca4 feat: 更新AddPingTask和EditPingTask函数以支持名称字段 2025-06-21 21:43:23 +08:00
Akizon77 356e71ad22 feat: 添加PingTask的名称字段 2025-06-21 19:27:19 +08:00
Akizon77 d6b58f6eed feat: 增加Ping任务管理功能 2025-06-21 16:47:39 +08:00
Akizon77 785f448baf wip: ping task
fix: Cannot add a column with non-constant default
2025-06-20 22:40:03 +08:00
Akizon77 194987f66a fix: 移除冷却时间检查 2025-06-20 22:28:30 +08:00
Akizon77 bbff63bd7d fix: #57 自动创建data文件夹 2025-06-20 22:19:33 +08:00
Akizon f654b71b31 Merge pull request #58 from komari-monitor/dev
feat: #57 增加connectedClients、latestReport线程安全
2025-06-20 22:07:15 +08:00
Akizon77 b85d1d1a48 feat: #57 增加connectedClients、latestReport线程安全 2025-06-20 21:57:30 +08:00
Akizon d669d2862e Update README.md 2025-06-19 21:48:33 +08:00
Akizon e6e71716c8 Merge pull request #55 from komari-monitor/dev
feat: 可配置的历史记录保存时长
2025-06-19 15:08:27 +08:00
Akizon77 5022148fd9 feat: 可配置的历史记录保存时长
wip: PING 检测
2025-06-19 15:07:17 +08:00
Akizon77 f069edc026 Merge branch 'dev' of https://github.com/komari-monitor/komari into dev 2025-06-19 14:31:06 +08:00
Akizon77 65cf7b30c3 wip: Ping、TCPing、HTTPing 2025-06-19 14:31:03 +08:00
Akizon 5110b42f0a Update todo.md 2025-06-19 12:54:31 +08:00
Akizon 92475f7331 Merge pull request #54 from komari-monitor/dev
feat: 补上站点名称和描述的坑
2025-06-18 23:54:57 +08:00
Akizon77 12ad38fc78 feat: 补上站点名称和描述的坑 2025-06-18 23:54:01 +08:00
Akizon77 dbf6e24d68 fix: 修复在保存客户端报告时的空UUID处理逻辑 2025-06-18 15:14:37 +08:00
Akizon 19d42a41dd Merge pull request #52 from komari-monitor/dev
refactor: 重构压缩数据库的方法
2025-06-17 17:53:29 +08:00
Akizon77 573fbdb6d8 feat: 下放获取历史记录的权限 2025-06-17 17:49:24 +08:00
Akizon77 52a7024e82 feat: 获取历史负载记录 2025-06-17 17:38:42 +08:00
Akizon77 600ae2e36f refactor: 重构压缩数据库的方法 2025-06-17 17:28:58 +08:00
Akizon d7b744a01a Merge pull request #50 from komari-monitor/main
同步
2025-06-16 00:34:57 +08:00
Akizon a3d4f8ca6a Merge pull request #49 from komari-monitor/dev
feat: 在启动服务器时根据版本哈希设置Gin模式
2025-06-16 00:34:17 +08:00
Akizon77 18ad4fe9de feat: 在启动服务器时根据版本哈希设置Gin模式 2025-06-16 00:33:38 +08:00
Akizon77 efb4cc6a4d ci/cd: 添加 GIN_MODE 环境变量以支持构建 2025-06-16 00:20:16 +08:00
Akizon77 09dd2ad329 ci/cd: 排除 Windows 平台的 arm64 架构 2025-06-16 00:03:38 +08:00
Akizon77 5f079c46d6 ci/cd: 更新交叉编译配置以支持 arm64 和 amd64 2025-06-15 23:59:53 +08:00
Akizon77 ec899e3ee4 ci/cd: arm64 2025-06-15 23:10:48 +08:00
Akizon f7813e4b1b Merge pull request #48 from komari-monitor/dev
ci/cd: 交叉编译
2025-06-15 22:58:47 +08:00
Akizon f5d00c0a2a Merge branch 'main' into dev 2025-06-15 22:58:41 +08:00
Akizon77 9b9348b341 ci/cd: 交叉编译 2025-06-15 22:56:51 +08:00
Akizon b5266cda66 Merge pull request #47 from komari-monitor/dev
feat: 添加离线通知功能,每个服务器可以单独设置
feat: 添加强制禁用2FA功能
2025-06-15 22:29:49 +08:00
Akizon77 d6d716c5ab fix: 修正通知开关字段的JSON标签拼写错误 2025-06-15 18:36:43 +08:00
Akizon77 de5a611718 feat: 添加强制禁用2FA功能 2025-06-15 17:41:09 +08:00
Akizon77 d595acbde3 feat: 添加离线通知功能,每个服务器可以单独设置 2025-06-15 17:32:27 +08:00
Akizon77 638ce9ae6c wip: 泛型查询 2025-06-15 15:26:14 +08:00
Akizon77 bd2c67d725 wip: 添加负载和离线通知模型 2025-06-15 15:18:03 +08:00
Akizon f8ae5418c9 fix: #45 当前编译禁用了 CGO 2025-06-15 00:55:16 +08:00
Akizon 5e1959dec3 Merge pull request #46 from komari-monitor/dev
feat: 添加在线和离线通知功能,支持冷却和宽限期设置
2025-06-15 00:18:52 +08:00
Akizon77 b7a276dd05 feat: 添加在线和离线通知功能,支持冷却和宽限期设置 2025-06-15 00:03:25 +08:00
Akizon b2b3db616a Update README.md 2025-06-14 21:25:59 +08:00
Akizon 17e1a8828c Merge pull request #43 from komari-monitor/dev
feat: 添加命令剪切板功能
2025-06-14 17:19:08 +08:00
Akizon77 1674220340 feat: 在Clipboard模型中添加Weight字段 2025-06-14 13:01:53 +08:00
Akizon77 ab76d51b84 feat: 添加命令剪切板功能 2025-06-13 22:57:19 +08:00
Akizon77 4863e623ae wip: 命令剪切板 2025-06-13 22:14:58 +08:00
Akizon77 ac88159d56 feat: 为Client模型添加Currency,默认值为'$' 2025-06-13 21:28:50 +08:00
Akizon 433bc52788 Merge pull request #42 from komari-monitor/dev
feat: 添加2FA支持
2025-06-11 19:35:59 +08:00
Akizon77 6aeb444b2b feat: 在更新用户信息时添加用户存在性检查 2025-06-07 17:06:24 +08:00
Akizon77 bbf631714d feat: 添加双因素认证支持 2025-06-07 16:30:16 +08:00
Akizon c204094173 Update todo.md 2025-06-07 15:38:52 +08:00
Akizon 417cec5d44 Update README.md 2025-06-07 15:34:03 +08:00
Akizon 5401b4c10e Update README.md 2025-06-07 15:32:54 +08:00
44 changed files with 1839 additions and 369 deletions

No files matched your search

+43 -4
View File
@@ -20,6 +20,25 @@ jobs:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64]
include:
- goos: windows
goarch: amd64
gcc: x86_64-w64-mingw32-gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: amd64
gcc: gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: arm64
gcc: aarch64-linux-gnu-gcc
CGO_CFLAGS: '-pthread'
CGO_LDFLAGS: '-pthread'
exclude:
- goos: windows
goarch: arm64
steps:
- name: Checkout code
@@ -28,7 +47,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '23'
node-version: "23"
- name: Clone and build frontend
run: |
@@ -43,13 +62,33 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version: "1.23"
- name: Install cross-compiler
run: |
sudo apt-get update
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
sudo apt-get install gcc-aarch64-linux-gnu
fi
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install mingw-w64
fi
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install wget xz-utils
mkdir -p /tmp/mingw64
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
fi
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
CGO_ENABLED: 1
CC: ${{ matrix.gcc }}
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
@@ -102,4 +141,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
VERSION_HASH=${{ github.sha }}
+11 -1
View File
@@ -84,7 +84,8 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- 感谢 [Alice Networks LTD](https://app.alice.ws/) 提供的免费服务器
- 感谢我自己能这么闲
- 广告位招租喵
## 引用
- [gorm.io](https://gorm.io/)
@@ -101,6 +102,15 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
## 许可证
[MIT License](LICENSE)
## 截图
![PixPin_2025-06-07_15-28-30](https://github.com/user-attachments/assets/edce5694-c6c8-4647-bc11-8b27105fd55c)
![PixPin_2025-06-07_15-28-49](https://github.com/user-attachments/assets/23b58032-211a-4b59-b444-c8267606a0bb)
![PixPin_2025-06-07_15-29-05](https://github.com/user-attachments/assets/f4325d4d-fa69-41c6-9251-19f530476e65)
![PixPin_2025-06-07_15-30-05](https://github.com/user-attachments/assets/69be38f6-6681-4afb-9a04-965d39b7fcc2)
![PixPin_2025-06-07_15-30-46](https://github.com/user-attachments/assets/01d84dd1-3c81-4424-a041-7dbc2ae5822e)
## 碎碎念
### 我为什么做Komari?
+5
View File
@@ -41,6 +41,11 @@ func SaveClientReportToDB() error {
}
}
Records[uuid] = filtered
if uuid == "" {
continue
}
r := utils.AverageReport(uuid, time.Now(), filtered)
record = append(record, r)
+54
View File
@@ -0,0 +1,54 @@
package admin
import (
"image/png"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/pquerna/otp/totp"
)
func Generate2FA(c *gin.Context) {
secret, img, err := accounts.Generate2Fa()
if err != nil {
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
c.Header("Content-Type", "image/png")
c.Writer.WriteHeader(200)
png.Encode(c.Writer, img)
}
func Enable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
secret, _ := c.Cookie("2fa_secret")
code := c.Query("code")
if secret == "" || uuid == nil || code == "" {
api.RespondError(c, 400, "2FA secret or code not provided")
return
}
if !totp.Validate(code, secret) {
api.RespondError(c, 400, "Invalid 2FA code")
return
}
err := accounts.Enable2Fa(uuid.(string), secret)
if err != nil {
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
api.RespondSuccess(c, "2FA enabled successfully")
}
func Disable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
err := accounts.Disable2Fa(uuid.(string))
if err != nil {
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
return
}
api.RespondSuccess(c, "")
}
+115
View File
@@ -0,0 +1,115 @@
package clipboard
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
// GetClipboard retrieves a clipboard entry by ID
func GetClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
return
}
api.RespondSuccess(c, cb)
}
// ListClipboard lists all clipboard entries
func ListClipboard(c *gin.Context) {
list, err := clipboardDB.ListClipboard()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
return
}
api.RespondSuccess(c, list)
}
// CreateClipboard creates a new clipboard entry
func CreateClipboard(c *gin.Context) {
var req models.Clipboard
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.CreateClipboard(&req); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
}
// UpdateClipboard updates an existing clipboard entry
func UpdateClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
var fields map[string]interface{}
if err := c.ShouldBindJSON(&fields); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
}
// DeleteClipboard deletes a clipboard entry
func DeleteClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
}
// BatchDeleteClipboard deletes multiple clipboard entries
func BatchDeleteClipboard(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
return
}
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
}
+2 -2
View File
@@ -25,7 +25,7 @@ func Exec(c *gin.Context) {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.ConnectedClients {
for uuid := range ws.GetConnectedClients() {
if contain(req.Clients, uuid) {
onlineClients = append(onlineClients, uuid)
} else {
@@ -53,7 +53,7 @@ func Exec(c *gin.Context) {
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.ConnectedClients[uuid]
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
} else {
+109
View File
@@ -0,0 +1,109 @@
package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm/clause"
)
// POST body : []uuid
func EnableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: true,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// POST body : []uuid
func DisableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: false,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func EditOfflineNotification(c *gin.Context) {
var notifications []models.OfflineNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
return
}
for _, noti := range notifications {
if noti.Client == "" {
api.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
}).
Select("*").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func ListOfflineNotifications(c *gin.Context) {
var notifications []models.OfflineNotification
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
}
+80
View File
@@ -0,0 +1,80 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
// POST body: clients []string, target, task_type string, interval int
func AddPingTask(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name" binding:"required"`
Target string `json:"target" binding:"required"`
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeletePingTask(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditPingTask(c *gin.Context) {
var req struct {
Tasks []*models.PingTask `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
for _, task := range req.Tasks {
tasks.DeletePingRecords([]uint{task.Id})
}
api.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
}
+2 -2
View File
@@ -7,12 +7,12 @@ import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/telegram"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestTelegram(c *gin.Context) {
message := "Komari test message."
err := telegram.SendTextMessage(message)
err := messageSender.SendTextMessage(message)
if err != nil {
api.RespondError(c, 500, "Failed to send Telegram message: "+err.Error())
return
+55 -14
View File
@@ -15,6 +15,9 @@ import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
)
@@ -47,7 +50,7 @@ func UploadReport(c *gin.Context) {
}
// Update report with method and token
ws.LatestReport[report.UUID] = &report
ws.GetLatestReport()[report.UUID] = &report
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -65,11 +68,12 @@ func WebSocketReport(c *gin.Context) {
},
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
conn := ws.NewSafeConn(unsafeConn)
defer conn.Close()
_, message, err := conn.ReadMessage()
@@ -105,14 +109,15 @@ func WebSocketReport(c *gin.Context) {
}
// 只允许一个客户端的连接
if _, exists := ws.ConnectedClients[uuid]; exists {
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
}
ws.ConnectedClients[uuid] = conn
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
defer func() {
delete(ws.ConnectedClients, uuid)
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
}()
for {
@@ -121,18 +126,54 @@ func WebSocketReport(c *gin.Context) {
break
}
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
break
type MessageType struct {
Type string `json:"type"`
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
var msgType MessageType
err = json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: reqBody.FinishedAt,
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
ws.LatestReport[uuid] = &report
}
}
+23 -14
View File
@@ -15,6 +15,7 @@ import (
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
@@ -40,22 +41,30 @@ func Login(c *gin.Context) {
return
}
if uuid, success := accounts.CheckPassword(data.Username, data.Password); success {
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
return
} else {
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
+1 -1
View File
@@ -22,6 +22,6 @@ func GetMe(c *gin.Context) {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID})
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
}
+10 -7
View File
@@ -13,13 +13,16 @@ func GetPublicSettings(c *gin.Context) {
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
})
}
+88
View File
@@ -0,0 +1,88 @@
package record
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
}
// GET query: uuid string, hours int
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
response := gin.H{
"records": records,
"count": len(records),
}
if len(records) > 0 {
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
})
}
response["tasks"] = tasksList
}
api.RespondSuccess(c, response)
}
+2 -2
View File
@@ -62,7 +62,7 @@ func RequestTerminal(c *gin.Context) {
return nil
})
if ws.ConnectedClients[uuid] == nil {
if ws.GetConnectedClients()[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.Close()
TerminalSessionsMutex.Lock()
@@ -70,7 +70,7 @@ func RequestTerminal(c *gin.Context) {
TerminalSessionsMutex.Unlock()
return
}
err = ws.ConnectedClients[uuid].WriteJSON(gin.H{
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
})
+9 -6
View File
@@ -5,11 +5,13 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/spf13/cobra"
)
var (
Username string
//Username string
NewPassword string
)
@@ -17,7 +19,7 @@ var ChpasswdCmd = &cobra.Command{
Use: "chpasswd",
Short: "Force change password",
Long: `Force change password`,
Example: `komari chpasswd -u <username> -p <password>`,
Example: `komari chpasswd -p <password>`,
Run: func(cmd *cobra.Command, args []string) {
if NewPassword == "" {
cmd.Help()
@@ -27,9 +29,10 @@ var ChpasswdCmd = &cobra.Command{
cmd.Println("Database file does not exist.")
return
}
cmd.Println("Changing password for user:", Username)
if err := accounts.ForceResetPassword(Username, NewPassword); err != nil {
user := &models.User{}
dbcore.GetDBInstance().Model(&models.User{}).First(user)
cmd.Println("Changing password for user:", user.Username)
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
cmd.Println("Error:", err)
return
}
@@ -45,7 +48,7 @@ var ChpasswdCmd = &cobra.Command{
}
func init() {
ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
//ChpasswdCmd.PersistentFlags().StringVarP(&Username, "user", "u", "admin", "The username of the account to change password")
ChpasswdCmd.PersistentFlags().StringVarP(&NewPassword, "password", "p", "", "New password")
RootCmd.AddCommand(ChpasswdCmd)
}
+33
View File
@@ -0,0 +1,33 @@
package cmd
import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var Disable2FA = &cobra.Command{
Use: "disable-2fa",
Short: "Force disable 2FA",
Long: `Force disable 2FA`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.User{}).Where("two_factor != ?", "").
Update("two_factor", "").Error
})
if err != nil {
cmd.Println("Error:", err)
os.Exit(1)
}
cmd.Println("2FA has been disabled.")
cmd.Println("Please restart the server to apply the changes.")
},
}
func init() {
RootCmd.AddCommand(Disable2FA)
}
+62 -8
View File
@@ -12,10 +12,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
"github.com/komari-monitor/komari/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/api/admin/log"
"github.com/komari-monitor/komari/api/admin/notification"
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
@@ -25,6 +28,7 @@ import (
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
@@ -39,7 +43,14 @@ var ServerCmd = &cobra.Command{
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
go geoip.InitGeoIp()
go DoScheduledWork()
@@ -80,7 +91,7 @@ var ServerCmd = &cobra.Command{
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
@@ -92,6 +103,9 @@ var ServerCmd = &cobra.Command{
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
// #region Agent
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
@@ -100,7 +114,7 @@ var ServerCmd = &cobra.Command{
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
// test
@@ -161,13 +175,49 @@ var ServerCmd = &cobra.Command{
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// 静态文件服务
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
@@ -226,19 +276,23 @@ func InitDatabase() {
}
}
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
ticker := time.NewTicker(time.Minute * 30)
ticker1 := time.NewTicker(60 * time.Second)
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * 24 * 30))
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
logOperation.RemoveOldLogs()
case <-ticker1.C:
case <-minute.C:
api.SaveClientReportToDB()
}
}
+58
View File
@@ -0,0 +1,58 @@
package accounts
import (
"image"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/pquerna/otp/totp"
)
var (
TwoFactorIssuer = "Komari Monitor"
)
func Generate2Fa() (string, image.Image, error) {
otp, err := totp.Generate(totp.GenerateOpts{
Issuer: TwoFactorIssuer,
AccountName: "komari",
})
if err != nil {
return "", nil, err
}
img, err := otp.Image(250, 250)
if err != nil {
return "", nil, err
}
return otp.Secret(), img, nil
}
func Enable2Fa(uuid, secret string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
}
func Verify2Fa(uuid, code string) (bool, error) {
db := dbcore.GetDBInstance()
var user models.User
err := db.Where("uuid = ?", uuid).First(&user).Error
if err != nil {
return false, err
}
if user.TwoFactor == "" {
return false, nil // 用户未启用2FA
}
valid := totp.Validate(code, user.TwoFactor)
if !valid {
return false, nil
}
return true, nil
}
func Disable2Fa(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
}
+6
View File
@@ -155,6 +155,12 @@ func UnbindExternalAccount(uuid string) error {
func UpdateUser(uuid string, name, password, sso_type *string) error {
db := dbcore.GetDBInstance()
// Check if user exists
var existingUser models.User
result := db.Where("uuid = ?", uuid).First(&existingUser)
if result.Error != nil {
return fmt.Errorf("user not found: %s", uuid)
}
updates := make(map[string]interface{})
if name != nil {
updates["username"] = *name
+58
View File
@@ -0,0 +1,58 @@
package clipboard
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
// CreateClipboard 创建剪贴板记录
func CreateClipboard(cb *models.Clipboard) error {
db := dbcore.GetDBInstance()
return db.Create(cb).Error
}
// GetClipboardByID 根据ID获取剪贴板记录
func GetClipboardByID(id int) (*models.Clipboard, error) {
var cb models.Clipboard
db := dbcore.GetDBInstance()
if err := db.First(&cb, id).Error; err != nil {
return nil, err
}
return &cb, nil
}
// UpdateClipboardFields 更新剪贴板记录
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteClipboard 删除剪贴板记录
func DeleteClipboard(id int) error {
db := dbcore.GetDBInstance()
// Check if record exists first
var cb models.Clipboard
if err := db.First(&cb, id).Error; err != nil {
return err // Record not found or other error
}
return db.Delete(&cb).Error
}
// DeleteClipboardBatch 批量删除剪贴板记录
func DeleteClipboardBatch(ids []int) error {
if len(ids) == 0 {
return nil
}
db := dbcore.GetDBInstance()
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
}
// ListClipboard 列出所有剪贴板记录
func ListClipboard() ([]models.Clipboard, error) {
var list []models.Clipboard
db := dbcore.GetDBInstance()
if err := db.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
+20 -38
View File
@@ -8,9 +8,11 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
@@ -80,49 +82,18 @@ var (
once sync.Once
)
// 初始化数据库
// 对于 SQLite:true 如果数据库文件存在,false 如果数据库文件不存在并被创建
// 对于 MySQL/其他数据库:总是返回 true
// func InitDatabase() bool {
// // 默认使用 SQLite 如果未指定类型
// if flags.DatabaseType == "" || flags.DatabaseType == "sqlite" {
// if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
// log.Printf("SQLite database file %q does not exist, creating...", flags.DatabaseFile)
// dbDir := filepath.Dir(flags.DatabaseFile)
// if dbDir != "" {
// if err := os.MkdirAll(dbDir, 0755); err != nil {
// log.Fatalf("Failed to create database file directory %q: %v", dbDir, err)
// }
// }
// file, err := os.Create(flags.DatabaseFile)
// if err != nil {
// log.Fatalf("Failed to create SQLite database file %q: %v", flags.DatabaseFile, err)
// }
// if err := file.Close(); err != nil {
// log.Fatalf("Failed to close database file %q: %v", flags.DatabaseFile, err)
// }
// return false
// } else if err != nil {
// log.Fatalf("Failed to check database file %q: %v", flags.DatabaseFile, err)
// }
// return true
// } else if flags.DatabaseType == "mysql" {
// // 对于 MySQL,我们不需要创建文件,只需检查连接信息是否有效
// log.Printf("Using MySQL database: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// return true
// } else {
// log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
// return false
// }
// }
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
logConfig := &gorm.Config{
//Logger: logger.Default.LogMode(logger.Silent),
Logger: logger.Default.LogMode(logger.Silent),
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
}
// 根据数据库类型选择不同的连接方式
@@ -160,10 +131,21 @@ func GetDBInstance() *gorm.DB {
&models.Record{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+10 -3
View File
@@ -19,10 +19,17 @@ type Config struct {
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
CreatedAt time.Time
UpdatedAt time.Time
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:72"` // Ping 记录保留时间,单位小时,默认3天
CreatedAt time.Time
UpdatedAt time.Time
}
+21
View File
@@ -1,6 +1,9 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
@@ -27,6 +30,7 @@ type Client struct {
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -39,6 +43,7 @@ type User struct {
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -79,3 +84,19 @@ type Record struct {
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
// StringArray represents a slice of strings stored as JSON in the database
// StringArray 存储为 JSON 的字符串切片类型
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
}
return json.Unmarshal(bytes, sa)
}
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
+24
View File
@@ -0,0 +1,24 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified time.Time `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
LastNotified time.Time `json:"last_notified"` // 上次通知时间
}
+19
View File
@@ -0,0 +1,19 @@
package models
import "time"
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
TaskId uint `json:"task_id" gorm:"not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:TaskId;references:Id"`
Time time.Time `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"`
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
}
-18
View File
@@ -1,27 +1,9 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// StringArray represents a slice of strings stored as JSON in the database
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
}
return json.Unmarshal(bytes, sa)
}
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
+1
View File
@@ -0,0 +1 @@
package notification
+228 -163
View File
@@ -2,6 +2,8 @@ package records
import (
"log"
"sort"
"strings"
"time"
"gorm.io/gorm"
@@ -18,7 +20,10 @@ func RecordOne(rec models.Record) error {
func DeleteAll() error {
db := dbcore.GetDBInstance()
return db.Exec("DELETE FROM Record").Error
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
@@ -29,98 +34,104 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
// 计算区间 [0.02, 0.98] 的平均值
func QuantileMean(values []float32) float32 {
if len(values) == 0 {
return 0
}
// 排序数据
sorted := make([]float32, len(values))
copy(sorted, values)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i] > sorted[j] {
sorted[i], sorted[j] = sorted[j], sorted[i]
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
threeHoursAgo := time.Now().Add(-3 * time.Hour)
interval := end.Sub(start)
if end.After(threeHoursAgo) {
var recentRecords []models.Record
recentStart := start
if recentStart.Before(threeHoursAgo) {
recentStart = threeHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
if interval > 4*time.Hour {
// 按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.After(old.Time) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.Before(groupedList[j].Time)
})
records = append(records, groupedList...)
} else {
// 查询区间不超过,直接返回全部recentRecords
records = append(records, recentRecords...)
}
}
// 区间边缘index
lower := int(0.02 * float64(len(sorted)))
upper := int(0.98 * float64(len(sorted)))
if lower >= upper || lower >= len(sorted) {
return 0
}
sum := float32(0)
count := 0
for i := lower; i < upper && i < len(sorted); i++ {
sum += sorted[i]
count++
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return records, nil
}
if count == 0 {
return 0
}
return sum / float32(count)
records = append(records, long_term...)
return records, nil
}
// 压缩数据库,针对每个ClientUUID,10分钟内的数据不动,24小时内的数据精简为每15分钟一条,3天为每30分钟一条,7天为每1小时一条,30天为每12小时一条。所有精简的数据是取 [0.02,0.98] 区间内的平均值
func GetAllRecords() ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
var long_term []models.Record
err := db.Table("records").Order("time ASC").Find(&records).Error
if err != nil {
log.Printf("Error fetching all records: %v", err)
return nil, err
}
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records: %v", err)
return records, nil
}
records = append(records, long_term...)
return records, nil
}
// 压缩数据库
func CompactRecord() error {
db := dbcore.GetDBInstance()
log.Printf("Compacting records...")
var clientUUIDs []string
if err := db.Model(&models.Record{}).Distinct("client").Pluck("client", &clientUUIDs).Error; err != nil {
err := migrateOldRecords(db)
if err != nil {
log.Printf("Error migrating old records: %v", err)
return err
}
now := time.Now()
tenMinutesAgo := now.Add(-10 * time.Minute)
oneDayAgo := now.Add(-24 * time.Hour)
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
thirtyDaysAgo := now.Add(-30 * 24 * time.Hour)
for _, clientUUID := range clientUUIDs {
// Process each time range with specific aggregation intervals
if err := processTimeRange(db, clientUUID, thirtyDaysAgo, sevenDaysAgo, 12*time.Hour, now); err != nil {
log.Printf("Error compacting 30d-7d for client %s: %v", clientUUID, err)
continue
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
if err := processTimeRange(db, clientUUID, sevenDaysAgo, threeDaysAgo, 1*time.Hour, now); err != nil {
log.Printf("Error compacting 7d-3d for client %s: %v", clientUUID, err)
continue
}
if err := processTimeRange(db, clientUUID, threeDaysAgo, oneDayAgo, 30*time.Minute, now); err != nil {
log.Printf("Error compacting 3d-1d for client %s: %v", clientUUID, err)
continue
}
if err := processTimeRange(db, clientUUID, oneDayAgo, tenMinutesAgo, 15*time.Minute, now); err != nil {
log.Printf("Error compacting 1d-10m for client %s: %v", clientUUID, err)
continue
}
}
if flags.DatabaseType != "sqlite" {
return nil // Only vacuum for SQLite
}
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming the database: %v", err)
}
log.Printf("Record compaction completed")
return nil
}
func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, interval time.Duration, now time.Time) error {
// Round start time to the nearest interval
start = start.Truncate(interval)
end = end.Truncate(interval)
func migrateOldRecords(db *gorm.DB) error {
// 计算 4 小时前的时间
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// Get all records in the time range
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).
Order("time ASC").
Find(&records).Error; err != nil {
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
return err
}
@@ -128,109 +139,163 @@ func processTimeRange(db *gorm.DB, clientUUID string, start, end time.Time, inte
return nil
}
// Group records by interval
type aggregatedRecord struct {
StartTime time.Time
Records []models.Record
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
Process []int
Connections []int
ConnectionsUdp []int
}
var aggregatedRecords []aggregatedRecord
currentGroup := aggregatedRecord{StartTime: start}
currentGroup.Records = make([]models.Record, 0)
groupedRecords := make(map[string]*groupData)
for _, record := range records {
groupTime := record.Time.Truncate(interval)
if !groupTime.Equal(currentGroup.StartTime) {
if len(currentGroup.Records) > 0 {
aggregatedRecords = append(aggregatedRecords, currentGroup)
key := record.Client + "_" + record.Time.Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
data := groupedRecords[key]
data.Cpu = append(data.Cpu, record.Cpu)
data.Gpu = append(data.Gpu, record.Gpu)
data.Load = append(data.Load, record.Load)
data.Temp = append(data.Temp, record.Temp)
data.Ram = append(data.Ram, record.Ram)
data.RamTotal = append(data.RamTotal, record.RamTotal)
data.Swap = append(data.Swap, record.Swap)
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
data.Disk = append(data.Disk, record.Disk)
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
data.NetIn = append(data.NetIn, record.NetIn)
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
}
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
getInt32Percentile := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int(getPercentile(floats, percentile))
}
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedRecords {
// 解析 Client 和时间
parts := strings.Split(key, "_")
clientUUID := parts[0]
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
if err != nil {
return err
}
currentGroup = aggregatedRecord{StartTime: groupTime}
currentGroup.Records = []models.Record{record}
} else {
currentGroup.Records = append(currentGroup.Records, record)
}
}
if len(currentGroup.Records) > 0 {
aggregatedRecords = append(aggregatedRecords, currentGroup)
}
// Begin transaction
tx := db.Begin()
if tx.Error != nil {
return tx.Error
}
cpuFloats := make([]float64, len(data.Cpu))
for i, v := range data.Cpu {
cpuFloats[i] = float64(v)
}
gpuFloats := make([]float64, len(data.Gpu))
for i, v := range data.Gpu {
gpuFloats[i] = float64(v)
}
loadFloats := make([]float64, len(data.Load))
for i, v := range data.Load {
loadFloats[i] = float64(v)
}
tempFloats := make([]float64, len(data.Temp))
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
high_percentile := 0.8
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
return err
}
// Delete original records in the time range
if err := tx.Where("client = ? AND time >= ? AND time < ?", clientUUID, start, end).Delete(&models.Record{}).Error; err != nil {
tx.Rollback()
return err
}
newRec := models.Record{
Client: clientUUID,
Time: timeSlot,
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
Temp: float32(getPercentile(tempFloats, high_percentile)),
Ram: getIntPercentile(data.Ram, high_percentile),
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
Swap: getIntPercentile(data.Swap, high_percentile),
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, high_percentile),
NetOut: getIntPercentile(data.NetOut, high_percentile),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
}
// Insert aggregated records
for _, agg := range aggregatedRecords {
if len(agg.Records) == 0 {
continue
// 如果记录已存在则更新,否则创建新记录
if existingCount > 0 {
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
return err
}
} else {
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
return err
}
}
}
// Calculate averages and sums
var sumCPU, sumGPU, sumLOAD, sumTEMP float32
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal int64
var sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
var sumPROCESS, sumConnections, sumConnectionsUDP int
count := len(agg.Records)
for _, r := range agg.Records {
sumCPU += r.Cpu
sumGPU += r.Gpu
sumLOAD += r.Load
sumTEMP += r.Temp
sumRAM += r.Ram
sumRAMTotal += r.RamTotal
sumSWAP += r.Swap
sumSWAPTotal += r.SwapTotal
sumDISK += r.Disk
sumDISKTotal += r.DiskTotal
sumNETIn += r.NetIn
sumNETOut += r.NetOut
sumNETTotalUp += r.NetTotalUp
sumNETTotalDown += r.NetTotalDown
sumPROCESS += r.Process
sumConnections += r.Connections
sumConnectionsUDP += r.ConnectionsUdp
}
// Create new aggregated record
newRecord := models.Record{
Client: clientUUID,
Time: agg.StartTime,
Cpu: sumCPU / float32(count),
Gpu: sumGPU / float32(count),
Ram: sumRAM / int64(count),
RamTotal: sumRAMTotal / int64(count),
Swap: sumSWAP / int64(count),
SwapTotal: sumSWAPTotal / int64(count),
Load: sumLOAD / float32(count),
Temp: sumTEMP / float32(count),
Disk: sumDISK / int64(count),
DiskTotal: sumDISKTotal / int64(count),
NetIn: sumNETIn / int64(count),
NetOut: sumNETOut / int64(count),
NetTotalUp: sumNETTotalUp / int64(count),
NetTotalDown: sumNETTotalDown / int64(count),
Process: sumPROCESS / count,
Connections: sumConnections / count,
ConnectionsUdp: sumConnectionsUDP / count,
}
if err := tx.Create(&newRecord).Error; err != nil {
tx.Rollback()
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo).Delete(&models.Record{}).Error; err != nil {
return err
}
}
return tx.Commit().Error
}
func Save(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
return nil
})
}
+130
View File
@@ -0,0 +1,130 @@
package records
import (
"encoding/csv"
"os"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/komari-monitor/komari/database/models"
)
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
var _ = func() bool {
// 确保 Test 环境中使用 sqlite 内存数据库
return true
}()
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
now := time.Now()
threshold := now.Add(-4 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: recTime, Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
err := db.Create(&rec).Error
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(time.Hour)
expectedGroups[slot] = struct{}{}
} else {
expectedRemain++
}
}
// 导出原始数据到 CSV
os.MkdirAll("../../data", 0755)
var origRecs []models.Record
db.Order("time desc").Find(&origRecs)
fOrig, err := os.Create("../../data/original.csv")
assert.NoError(t, err)
defer fOrig.Close()
wOrig := csv.NewWriter(fOrig)
defer wOrig.Flush()
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
// 运行压缩(迁移)逻辑
err = migrateOldRecords(db)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
var longCount int64
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
assert.Equal(t, int64(len(expectedGroups)), longCount)
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRemain), remainCount+1)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
db.Table("records_long_term").Order("time desc").Find(&compRecs)
fComp, err := os.Create("../../data/compressed.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp := csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
db.Table("records").Order("time desc").Find(&compRecs)
fComp, err = os.Create("../../data/compressed_records.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp = csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
}
+103
View File
@@ -0,0 +1,103 @@
package tasks
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
)
func AddPingTask(clients []string, name string, target, task_type string, interval int) (uint, error) {
db := dbcore.GetDBInstance()
task := models.PingTask{
Clients: clients,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
}
if err := db.Create(&task).Error; err != nil {
return 0, err
}
ReloadPingSchedule()
return task.Id, nil
}
func DeletePingTask(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
ReloadPingSchedule()
return result.Error
}
func EditPingTask(tasks []*models.PingTask) error {
db := dbcore.GetDBInstance()
for _, task := range tasks {
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(task)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
}
ReloadPingSchedule()
return nil
}
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func SavePingRecord(record models.PingRecord) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
return err
}
func DeletePingRecords(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func ReloadPingSchedule() error {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
if err := db.Find(&pingTasks).Error; err != nil {
return err
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
+5 -2
View File
@@ -3,11 +3,11 @@ module github.com/komari-monitor/komari
go 1.23.2
require (
github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
gorm.io/driver/mysql v1.5.7
@@ -26,6 +26,7 @@ require golang.org/x/net v0.39.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
@@ -43,7 +44,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
@@ -51,6 +52,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
@@ -58,5 +60,6 @@ require (
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+12 -2
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -15,8 +17,6 @@ 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/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/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
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=
@@ -53,8 +53,12 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -72,8 +76,12 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -111,8 +119,10 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13 -3
View File
@@ -4,6 +4,7 @@ import (
"embed"
"errors"
"fmt"
"html"
"io"
"io/fs"
"log"
@@ -47,13 +48,22 @@ func initIndex() {
RawIndexFile = string(index)
}
func UpdateIndex(cfg models.Config) {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "<title>Komari Monitor</title>"
} else {
titleReplacement = fmt.Sprintf("<title>%s - Komari Monitor</title>", html.EscapeString(cfg.Sitename))
}
replaceMap := map[string]string{
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
"<title>Komari Monitor</title>": titleReplacement,
"A simple server monitor tool.": cfg.Description,
"</head>": cfg.CustomHead + "</head>",
"</body>": cfg.CustomBody + "</body>",
}
updated := RawIndexFile
for k, v := range replaceMap {
updated = strings.Replace(updated, k, v, 1)
updated = strings.Replace(updated, k, v, -1)
}
IndexFile = updated
}
+6 -6
View File
@@ -6,9 +6,9 @@
- [x] Web终端
- [ ] 一键部署脚本
- [x] 一键部署脚本
- [ ] 远程命令执行
- [x] 远程命令执行
## Serser
@@ -20,13 +20,13 @@
- [x] 命令行指令 chpasswd 强制更换密码
- [ ] Github单点登录、绑定、解绑
- [x] Github单点登录、绑定、解绑
- [ ] 2FA
- [x] 2FA
- [ ] 负载通知
- [ ] 离线通知
- [x] 离线通知
- [ ] DDNS
@@ -36,4 +36,4 @@
- [x] 自动更新
- [ ] 慢速模式
- [x] 慢速模式(调整interval)
+22
View File
@@ -0,0 +1,22 @@
package messageSender
import (
"errors"
"github.com/komari-monitor/komari/database/config"
)
func SendTextMessage(message string) error {
cfg, _ := config.Get()
if !cfg.NotificationEnabled {
return nil
}
switch cfg.NotificationMethod {
case "none":
return nil
case "telegram":
return TelegramSendTextMessage(message)
default:
return errors.New("unsupported notification method: " + cfg.NotificationMethod)
}
}
+76
View File
@@ -0,0 +1,76 @@
package messageSender
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
)
func TelegramSendMessage(message, msgType string) error {
var lastErr error
for i := 0; i < 3; i++ {
conf, err := config.Get()
if err != nil {
lastErr = err
continue
}
if message == "" {
return errors.New("telegram is disabled or message is empty")
}
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", conf.TelegramChatID)
data.Set("text", message)
if msgType != "text" {
data.Set("parse_mode", msgType)
}
resp, err := http.PostForm(endpoint, data)
if err != nil {
lastErr = fmt.Errorf("failed to send message: %v", err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
continue
}
var result struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
lastErr = err
continue
}
if !result.Ok {
lastErr = fmt.Errorf("telegram API error: %s", result.Description)
continue
}
return nil
}
return lastErr
}
// SendTextMessage sends a text message via Telegram API
func TelegramSendTextMessage(message string) error {
return TelegramSendMessage(message, "text")
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func TelegramSendMarkdownMessage(message string) error {
return TelegramSendMessage(message, "MarkdownV2")
}
func TelegramSendHTMLMessage(message string) error {
return TelegramSendMessage(message, "HTML")
}
+112
View File
@@ -0,0 +1,112 @@
package notification
import (
"fmt"
"log"
"sync"
"time"
"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/utils/messageSender"
)
var (
pendingOffline = make(map[string]time.Time)
mu sync.Mutex
)
// OfflineNotification sends an offline notification for the client if enabled and not sent in grace period
func OfflineNotification(clientID string) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
now := time.Now()
conf, _ := config.Get()
if !conf.NotificationEnabled {
return
}
noti_conf := models.OfflineNotification{
Client: clientID,
}
db := dbcore.GetDBInstance()
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&noti_conf).Error
if err != nil {
log.Println("Failed to get or create offline notification config:", err)
return
}
if !noti_conf.Enable {
return
}
gracePeriod := time.Duration(noti_conf.GracePeriod) * time.Second
if gracePeriod <= 0 {
gracePeriod = 5 * time.Minute // default grace period if not set
}
mu.Lock()
if _, exists := pendingOffline[clientID]; exists {
mu.Unlock()
return
}
pendingOffline[clientID] = now
mu.Unlock()
go func(start time.Time) {
time.Sleep(gracePeriod)
mu.Lock()
defer mu.Unlock()
// if no longer pending (client reconnected), skip
if ts, ok := pendingOffline[clientID]; !ok || !ts.Equal(start) {
return
}
// record notification time and clear pending
delete(pendingOffline, clientID)
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
_ = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now)
}(now)
}
// OnlineNotification sends an online notification for the client if enabled
func OnlineNotification(clientID string) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
noti_conf := models.OfflineNotification{
Client: clientID,
}
db := dbcore.GetDBInstance()
err = db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).FirstOrCreate(&noti_conf).Error
if err != nil {
log.Println("Failed to get or create offline notification config:", err)
return
}
if !noti_conf.Enable {
return
}
// clear any pending offline debounce
mu.Lock()
delete(pendingOffline, clientID)
mu.Unlock()
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
}
+94
View File
@@ -0,0 +1,94 @@
package utils
import (
"sync"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
)
// PingTaskManager 管理定时器和任务
type PingTaskManager struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.PingTask
stopChan chan struct{}
}
var manager = &PingTaskManager{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.PingTask),
stopChan: make(chan struct{}),
}
// Reload 重载时间表
func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
}
m.tickers = make(map[int]*time.Ticker)
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
taskGroups := make(map[int][]models.PingTask)
for _, task := range pingTasks {
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
m.tickers[interval] = ticker
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.PingTask) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executePingTask(task)
}
case <-m.stopChan:
return
}
}
}(ticker, tasks)
}
return nil
}
// executePingTask 执行单个PingTask
func executePingTask(task models.PingTask) {
clients := ws.GetConnectedClients()
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
for _, clientUUID := range task.Clients {
if conn, exists := clients[clientUUID]; exists {
if conn == nil {
continue
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
if err := conn.WriteJSON(message); err != nil {
continue
}
}
}
}
// ReloadPingSchedule 加载或重载时间表
func ReloadPingSchedule(pingTasks []models.PingTask) error {
return manager.Reload(pingTasks)
}
-67
View File
@@ -1,67 +0,0 @@
package telegram
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/komari-monitor/komari/database/config"
)
func SendMessage(message, msgType string) error {
conf, err := config.Get()
if err != nil {
return err
}
if !conf.TelegramEnabled || message == "" {
return errors.New("telegram is disabled or message is empty")
}
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
data := url.Values{}
data.Set("chat_id", conf.TelegramChatID)
data.Set("text", message)
if msgType != "text" {
data.Set("parse_mode", msgType)
}
resp, err := http.PostForm(endpoint, data)
if err != nil {
return fmt.Errorf("failed to send message: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
}
var result struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Ok {
return fmt.Errorf("telegram API error: %s", result.Description)
}
return nil
}
// SendTextMessage sends a text message via Telegram API
func SendTextMessage(message string) error {
return SendMessage(message, "text")
}
// SendMarkdownMessage sends a message formatted in Markdown via Telegram API
func SendMarkdownMessage(message string) error {
return SendMessage(message, "MarkdownV2")
}
func SendHTMLMessage(message string) error {
return SendMessage(message, "HTML")
}
+4 -4
View File
@@ -47,15 +47,15 @@ func GetClients(c *gin.Context) {
continue
}
// 已建立连接的客户端uuid列表
for key := range ConnectedClients {
for key := range GetConnectedClients() {
resp.Online = append(resp.Online, key)
}
// 清除UUID,简化报告单
for _, report := range LatestReport {
resp.Data = GetLatestReport()
for _, report := range resp.Data {
report.UUID = ""
}
resp.Data = LatestReport
for _, report := range LatestReport {
for _, report := range resp.Data {
if report.CPU.Usage == 0 {
report.CPU.Usage = 0.01
}
+48 -2
View File
@@ -1,12 +1,58 @@
package ws
import (
"sync"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
)
var (
ConnectedClients = make(map[string]*websocket.Conn)
connectedClients = make(map[string]*SafeConn)
ConnectedUsers = []*websocket.Conn{}
LatestReport = make(map[string]*common.Report)
latestReport = make(map[string]*common.Report)
mu = sync.RWMutex{}
)
func GetConnectedClients() map[string]*SafeConn {
mu.RLock()
defer mu.RUnlock()
clientsCopy := make(map[string]*SafeConn)
for k, v := range connectedClients {
clientsCopy[k] = v
}
return clientsCopy
}
func SetConnectedClients(uuid string, conn *SafeConn) {
mu.Lock()
defer mu.Unlock()
connectedClients[uuid] = conn
}
func DeleteConnectedClients(uuid string) {
mu.Lock()
defer mu.Unlock()
if conn, exists := connectedClients[uuid]; exists {
conn.Close()
}
delete(connectedClients, uuid)
}
func GetLatestReport() map[string]*common.Report {
mu.RLock()
defer mu.RUnlock()
reportCopy := make(map[string]*common.Report)
for k, v := range latestReport {
reportCopy[k] = v
}
return reportCopy
}
func SetLatestReport(uuid string, report *common.Report) {
mu.Lock()
defer mu.Unlock()
latestReport[uuid] = report
}
func DeleteLatestReport(uuid string) {
mu.Lock()
defer mu.Unlock()
delete(latestReport, uuid)
}
+52
View File
@@ -0,0 +1,52 @@
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
type SafeConn struct {
conn *websocket.Conn
mu sync.Mutex
}
func NewSafeConn(conn *websocket.Conn) *SafeConn {
return &SafeConn{
conn: conn,
mu: sync.Mutex{},
}
}
func (sc *SafeConn) WriteMessage(messageType int, data []byte) error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.WriteMessage(messageType, data)
}
func (sc *SafeConn) WriteJSON(v interface{}) error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.WriteJSON(v)
}
func (sc *SafeConn) Close() error {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn.Close()
}
func (sc *SafeConn) ReadMessage() (int, []byte, error) {
return sc.conn.ReadMessage()
}
func (sc *SafeConn) ReadJSON(v interface{}) error {
return sc.conn.ReadJSON(v)
}
func (sc *SafeConn) SetReadDeadline(t time.Time) error {
return sc.conn.SetReadDeadline(t)
}
func (sc *SafeConn) GetConn() *websocket.Conn {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.conn
}