mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 17:00:18 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e415da8d4 | ||
|
|
a125466b75 | ||
|
|
a9306cd651 | ||
|
|
6af72f41c0 | ||
|
|
e9e04593c7 | ||
|
|
43b39c9f68 | ||
|
|
8d5ce55301 | ||
|
|
e72d461869 | ||
|
|
5cc4b518ba | ||
|
|
8aed055cc4 | ||
|
|
a306b6911a | ||
|
|
776dfee23b | ||
|
|
02e01a1d7c | ||
|
|
6b0b5b4855 | ||
|
|
9dd09731bd | ||
|
|
963c829164 | ||
|
|
d01fafbf0f | ||
|
|
729db05346 | ||
|
|
70dc06a761 | ||
|
|
be059b7aa3 | ||
|
|
b80c917f25 | ||
|
|
6b74bf90ce | ||
|
|
a2e4665ff5 | ||
|
|
2c9bd9c847 | ||
|
|
54eb957618 | ||
|
|
370f0ff815 | ||
|
|
9bd9a280e5 | ||
|
|
daf6ae291f | ||
|
|
3d774eec81 | ||
|
|
129e0be151 |
No files matched your search
@@ -78,7 +78,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
这个坑晚点再填吧 (o゜▽゜)o☆
|
||||
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
@@ -87,46 +87,6 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
- 感谢我自己能这么闲
|
||||
- 广告位招租喵
|
||||
|
||||
## 引用
|
||||
- [gorm.io](https://gorm.io/)
|
||||
- [spf13/cobra](https://github.com/spf13/cobra)
|
||||
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
|
||||
- [gorilla/websocket](https://github.com/gorilla/websocket)
|
||||
- [google/uuid](https://github.com/google/uuid)
|
||||
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
|
||||
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
|
||||
- [creack/pty](https://github.com/creack/pty)
|
||||
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
|
||||
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
|
||||
|
||||
## 许可证
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 截图
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## 碎碎念
|
||||
|
||||
### 我为什么做Komari?
|
||||
|
||||
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
|
||||
|
||||
> @古xx斯
|
||||
> 哪吒?
|
||||
> > 数据至少保存15天
|
||||
|
||||
> @sxxu
|
||||
> 怕不是做梦的预算
|
||||
|
||||
> @Vxxn
|
||||
> prometheus加grafana
|
||||
> > 部署太麻烦
|
||||
|
||||
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
+6
-5
@@ -23,10 +23,11 @@ var (
|
||||
)
|
||||
|
||||
type TerminalSession struct {
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
UUID string
|
||||
UserUUID string
|
||||
Browser *websocket.Conn
|
||||
Agent *websocket.Conn
|
||||
RequesterIp string
|
||||
}
|
||||
|
||||
var TerminalSessionsMutex = &sync.Mutex{}
|
||||
@@ -61,7 +62,7 @@ func SaveClientReportToDB() error {
|
||||
|
||||
// 计算平均报告并添加到记录列表
|
||||
if len(filtered) > 0 {
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered)
|
||||
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
|
||||
records = append(records, r)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -4,14 +4,13 @@ import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
@@ -80,24 +79,35 @@ func DownloadBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果数据库文件不在 ./data 目录下,单独添加
|
||||
dbFilePath := flags.DatabaseFile
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
// 获取 ./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
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(filepath.Clean(dbFilePath), filepath.Clean("./data")) {
|
||||
// 不在 ./data,单独添加
|
||||
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) {
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
dbInfo, err := os.Stat(dbFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
|
||||
// 数据库文件不存在不影响主流程
|
||||
return
|
||||
return // 数据库不存在是正常情况,直接返回
|
||||
}
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !dbInfo.IsDir() { // 确保是文件
|
||||
if !dbInfo.IsDir() {
|
||||
file, err := os.Open(dbFilePath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
|
||||
@@ -105,7 +115,6 @@ func DownloadBackup(c *gin.Context) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 直接用文件名放在 zip 根目录
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: dbFileName,
|
||||
Method: zip.Deflate,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package notification
|
||||
@@ -0,0 +1,90 @@
|
||||
package notification
|
||||
|
||||
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/notification"
|
||||
)
|
||||
|
||||
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
|
||||
func AddLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Metric string `json:"metric" binding:"required"`
|
||||
Threshold float32 `json:"threshold" binding:"required"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" binding:"required"` // 达标时间比
|
||||
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Interval > 4*60 || req.Interval <= 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
|
||||
return
|
||||
}
|
||||
if req.Ratio <= 0 || req.Ratio > 1 {
|
||||
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
|
||||
return
|
||||
}
|
||||
|
||||
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, 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 DeleteLoadNotification(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 := notification.DeleteLoadNotification(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 EditLoadNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Notifications []*models.LoadNotification `json:"notifications" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
|
||||
return
|
||||
}
|
||||
|
||||
if err := notification.EditLoadNotification(req.Notifications); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
// for _, notification := range req.Notifications {
|
||||
// notification.DeleteLoadNotification([]uint{notification.Id})
|
||||
// }
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications(c *gin.Context) {
|
||||
notifications, err := notification.GetAllLoadNotifications()
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestGeoIp(c *gin.Context) {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
GeoIpRecord, err := geoip.CurrentProvider.GetGeoInfo(net.ParseIP(ip))
|
||||
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func UpdateMmdbGeoIP(c *gin.Context) {
|
||||
if err := geoip.CurrentProvider.UpdateDatabase(); err != nil {
|
||||
if err := geoip.UpdateDatabase(); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ func UploadBasicInfo(c *gin.Context) {
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.CurrentProvider.GetGeoInfo(ip4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.CurrentProvider.GetGeoInfo(ip6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
|
||||
}
|
||||
|
||||
+8
-7
@@ -41,10 +41,11 @@ func RequestTerminal(c *gin.Context) {
|
||||
// 新建一个终端连接
|
||||
id := utils.GenerateRandomString(32)
|
||||
session := &TerminalSession{
|
||||
UserUUID: user_uuid.(string),
|
||||
UUID: uuid,
|
||||
Browser: conn,
|
||||
Agent: nil,
|
||||
UserUUID: user_uuid.(string),
|
||||
UUID: uuid,
|
||||
Browser: conn,
|
||||
Agent: nil,
|
||||
RequesterIp: c.ClientIP(),
|
||||
}
|
||||
|
||||
TerminalSessionsMutex.Lock()
|
||||
@@ -95,7 +96,7 @@ func RequestTerminal(c *gin.Context) {
|
||||
}
|
||||
TerminalSessionsMutex.Unlock()
|
||||
})
|
||||
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
//logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
|
||||
}
|
||||
|
||||
func ForwardTerminal(id string) {
|
||||
@@ -104,7 +105,7 @@ func ForwardTerminal(id string) {
|
||||
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
|
||||
return
|
||||
}
|
||||
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
|
||||
logOperation.Log(session.RequesterIp, session.UserUUID, "established, terminal id:"+id, "terminal")
|
||||
established_time := time.Now()
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
@@ -161,7 +162,7 @@ func ForwardTerminal(id string) {
|
||||
session.Browser.Close()
|
||||
}
|
||||
disconnect_time := time.Now()
|
||||
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
logOperation.Log(session.RequesterIp, session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
|
||||
TerminalSessionsMutex.Lock()
|
||||
delete(TerminalSessions, id)
|
||||
TerminalSessionsMutex.Unlock()
|
||||
|
||||
+8
-8
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
@@ -19,13 +19,13 @@ func getEnv(key, defaultValue string) string {
|
||||
|
||||
// 从环境变量获取默认值
|
||||
var (
|
||||
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
|
||||
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
|
||||
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
|
||||
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
|
||||
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
|
||||
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
|
||||
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
|
||||
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
|
||||
+21
-1
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -25,10 +26,12 @@ import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
"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/cloudflared"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
u_notification "github.com/komari-monitor/komari/utils/notification"
|
||||
@@ -57,6 +60,13 @@ var ServerCmd = &cobra.Command{
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
|
||||
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
|
||||
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to run cloudflared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 动态 CORS 中间件
|
||||
@@ -212,6 +222,13 @@ var ServerCmd = &cobra.Command{
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
loadAlertGroup := notificationGroup.Group("/load")
|
||||
{
|
||||
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
|
||||
loadAlertGroup.POST("/add", notification.AddLoadNotification)
|
||||
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
|
||||
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
|
||||
}
|
||||
}
|
||||
|
||||
pingTaskGroup := adminAuthrized.Group("/ping")
|
||||
@@ -259,7 +276,7 @@ var ServerCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
// 从环境变量获取监听地址
|
||||
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
|
||||
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
|
||||
RootCmd.AddCommand(ServerCmd)
|
||||
}
|
||||
@@ -290,6 +307,7 @@ func InitDatabase() {
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
tasks.ReloadPingSchedule()
|
||||
d_notification.ReloadLoadNotificationSchedule()
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
@@ -317,8 +335,10 @@ func DoScheduledWork() {
|
||||
|
||||
func OnShutdown() {
|
||||
logOperation.Log("", "", "server is shutting down", "info")
|
||||
cloudflared.Kill()
|
||||
}
|
||||
|
||||
func OnFatal(err error) {
|
||||
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
|
||||
cloudflared.Kill()
|
||||
}
|
||||
@@ -75,6 +75,10 @@ func MergeDatabase(db *gorm.DB) {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -140,6 +140,7 @@ func GetAppLocation() *time.Location {
|
||||
appLocation = defaultLocation
|
||||
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
|
||||
}
|
||||
time.Local = appLocation // 设置全局时区
|
||||
})
|
||||
return appLocation
|
||||
}
|
||||
+13
-11
@@ -31,26 +31,28 @@ type Client struct {
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
type User struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
|
||||
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
|
||||
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Username string `json:"username" gorm:"type:varchar(50);unique;not null"`
|
||||
Passwd string `json:"passwd,omitempty" gorm:"type:varchar(255);not null"` // Hashed password
|
||||
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
type Session struct {
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
@@ -63,7 +65,7 @@ type Session struct {
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time UTCTime `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
|
||||
@@ -2,8 +2,9 @@ package models
|
||||
|
||||
// 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"`
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"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 UTCTime `json:"last_notified"` // 上次通知时间
|
||||
@@ -11,12 +12,12 @@ type OfflineNotification struct {
|
||||
|
||||
// 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 UTCTime `json:"last_notified"` // 上次通知时间
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
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"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package models
|
||||
|
||||
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 UTCTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time UTCTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
type PingTask struct {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package models
|
||||
|
||||
type Task struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Command string `json:"command" gorm:"type:text"`
|
||||
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36)"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *UTCTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
load_notification "github.com/komari-monitor/komari/utils/notification"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
notification := models.LoadNotification{
|
||||
Clients: clients,
|
||||
Name: name,
|
||||
Metric: metric,
|
||||
Threshold: threshold,
|
||||
Ratio: ratio,
|
||||
Interval: interval,
|
||||
}
|
||||
if err := db.Create(¬ification).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return notification.Id, ReloadLoadNotificationSchedule()
|
||||
}
|
||||
func DeleteLoadNotification(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func EditLoadNotification(notifications []*models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, notification := range notifications {
|
||||
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.LoadNotification
|
||||
if err := db.Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func SaveLoadNotification(record models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
}
|
||||
|
||||
func ReloadLoadNotificationSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var loadNotifications []models.LoadNotification
|
||||
if err := db.Find(&loadNotifications).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return load_notification.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package notification
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Color definitions for terminal output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log_info() {
|
||||
echo -e "$1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}$1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}$1${NC}"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
# Global variables
|
||||
INSTALL_DIR="/opt/komari"
|
||||
DATA_DIR="/opt/komari/data"
|
||||
SERVICE_NAME="komari"
|
||||
BINARY_PATH="$INSTALL_DIR/komari"
|
||||
|
||||
# Show banner
|
||||
show_banner() {
|
||||
clear
|
||||
echo "=============================================================="
|
||||
echo " Komari Monitoring System Installer"
|
||||
echo " https://github.com/komari-monitor/komari"
|
||||
echo "=============================================================="
|
||||
echo
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "请使用 root 权限运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for systemd
|
||||
check_systemd() {
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect system architecture
|
||||
detect_arch() {
|
||||
local arch=$(uname -m)
|
||||
case $arch in
|
||||
x86_64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64)
|
||||
echo "arm64"
|
||||
;;
|
||||
i386|i686)
|
||||
echo "386"
|
||||
;;
|
||||
riscv64)
|
||||
echo "riscv64"
|
||||
;;
|
||||
*)
|
||||
log_error "不支持的架构: $arch"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check if Komari is already installed
|
||||
is_installed() {
|
||||
if [ -f "$BINARY_PATH" ]; then
|
||||
return 0 # 0 means true in bash exit codes
|
||||
else
|
||||
return 1 # 1 means false
|
||||
fi
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
install_dependencies() {
|
||||
log_step "检查并安装依赖..."
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
log_info "使用 apt 安装依赖..."
|
||||
apt update
|
||||
apt install -y curl
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
log_info "使用 yum 安装依赖..."
|
||||
yum install -y curl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
log_info "使用 apk 安装依赖..."
|
||||
apk add curl
|
||||
else
|
||||
log_error "未找到支持的包管理器 (apt/yum/apk)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Binary installation
|
||||
install_binary() {
|
||||
log_step "开始二进制安装..."
|
||||
|
||||
if is_installed; then
|
||||
log_info "Komari 已安装。要升级,请使用升级选项。"
|
||||
return
|
||||
fi
|
||||
|
||||
install_dependencies
|
||||
|
||||
local arch=$(detect_arch)
|
||||
log_info "检测到架构: $arch"
|
||||
|
||||
log_step "创建安装目录: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
log_step "创建数据目录: $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
local file_name="komari-linux-${arch}"
|
||||
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
|
||||
|
||||
log_step "下载 Komari 二进制文件..."
|
||||
log_info "URL: $download_url"
|
||||
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
log_error "下载失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$BINARY_PATH"
|
||||
log_success "Komari 二进制文件安装完成: $BINARY_PATH"
|
||||
|
||||
if ! check_systemd; then
|
||||
log_step "警告:未检测到 systemd,跳过服务创建。"
|
||||
log_step "您可以从命令行手动运行 Komari:"
|
||||
log_step " $BINARY_PATH server -l 0.0.0.0:25774"
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
return
|
||||
fi
|
||||
|
||||
create_systemd_service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}.service
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
log_success "Komari 服务启动成功"
|
||||
|
||||
log_step "正在获取初始密码..."
|
||||
sleep 5
|
||||
local password=$(journalctl -u ${SERVICE_NAME} --since "1 minute ago" | grep "admin account created." | tail -n 1 | sed -e 's/.*admin account created.//')
|
||||
if [ -z "$password" ]; then
|
||||
log_error "未能获取初始密码,请检查日志"
|
||||
fi
|
||||
show_access_info "$password"
|
||||
else
|
||||
log_error "Komari 服务启动失败"
|
||||
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Create systemd service file
|
||||
create_systemd_service() {
|
||||
log_step "创建 systemd 服务..."
|
||||
|
||||
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
cat > "$service_file" << EOF
|
||||
[Unit]
|
||||
Description=Komari Monitor Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${BINARY_PATH} server -l 0.0.0.0:25774
|
||||
WorkingDirectory=${DATA_DIR}
|
||||
Restart=always
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
log_success "systemd 服务文件创建完成"
|
||||
}
|
||||
|
||||
# Show access information
|
||||
show_access_info() {
|
||||
local password=$1
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
echo
|
||||
log_info "访问信息:"
|
||||
log_info " URL: http://$(hostname -I | awk '{print $1}'):25774"
|
||||
if [ -n "$password" ]; then
|
||||
log_info "初始登录信息: $password"
|
||||
fi
|
||||
echo
|
||||
log_info "服务管理命令:"
|
||||
log_info " 状态: systemctl status $SERVICE_NAME"
|
||||
log_info " 启动: systemctl start $SERVICE_NAME"
|
||||
log_info " 停止: systemctl stop $SERVICE_NAME"
|
||||
log_info " 重启: systemctl restart $SERVICE_NAME"
|
||||
log_info " 日志: journalctl -u $SERVICE_NAME -f"
|
||||
}
|
||||
|
||||
# Upgrade function
|
||||
upgrade_komari() {
|
||||
log_step "升级 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
log_error "Komari 未安装。请先安装它。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! check_systemd; then
|
||||
log_error "未检测到 systemd。无法管理服务。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
|
||||
log_step "备份当前二进制文件..."
|
||||
cp "$BINARY_PATH" "${BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
local arch=$(detect_arch)
|
||||
local file_name="komari-linux-${arch}"
|
||||
local download_url="https://github.com/komari-monitor/komari/releases/latest/download/${file_name}"
|
||||
|
||||
log_step "下载最新版本..."
|
||||
if ! curl -L -o "$BINARY_PATH" "$download_url"; then
|
||||
log_error "下载失败,正在从备份恢复"
|
||||
mv "${BINARY_PATH}.backup."* "$BINARY_PATH"
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
log_step "重启 Komari 服务..."
|
||||
systemctl start ${SERVICE_NAME}.service
|
||||
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
log_success "Komari 升级成功"
|
||||
else
|
||||
log_error "服务在升级后未能启动"
|
||||
fi
|
||||
}
|
||||
|
||||
# Uninstall function
|
||||
uninstall_komari() {
|
||||
log_step "卸载 Komari..."
|
||||
|
||||
if ! is_installed; then
|
||||
log_info "Komari 未安装"
|
||||
return 0
|
||||
fi
|
||||
|
||||
read -p "这将删除 Komari。您确定吗?(Y/n): " confirm
|
||||
if [[ $confirm =~ ^[Nn]$ ]]; then
|
||||
log_info "卸载已取消"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if check_systemd; then
|
||||
log_step "停止并禁用服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service >/dev/null 2>&1
|
||||
systemctl disable ${SERVICE_NAME}.service >/dev/null 2>&1
|
||||
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
log_success "systemd 服务已删除"
|
||||
fi
|
||||
|
||||
log_step "删除二进制文件..."
|
||||
rm -f "$BINARY_PATH"
|
||||
# 尝试在目录为空时删除该目录
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null || log_info "数据目录 $INSTALL_DIR 不为空,未删除"
|
||||
log_success "Komari 二进制文件已删除"
|
||||
|
||||
log_success "Komari 卸载完成"
|
||||
log_info "数据文件保留在 $DATA_DIR"
|
||||
}
|
||||
|
||||
# Show service status
|
||||
show_status() {
|
||||
if ! is_installed; then
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
log_error "未检测到 systemd。无法获取服务状态。"
|
||||
return
|
||||
fi
|
||||
log_step "Komari 服务状态:"
|
||||
systemctl status ${SERVICE_NAME}.service --no-pager -l
|
||||
}
|
||||
|
||||
# Show service logs
|
||||
show_logs() {
|
||||
if ! is_installed; then
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
log_error "未检测到 systemd。无法获取服务日志。"
|
||||
return
|
||||
fi
|
||||
log_step "查看 Komari 服务日志..."
|
||||
journalctl -u ${SERVICE_NAME} -f --no-pager
|
||||
}
|
||||
|
||||
# Restart service
|
||||
restart_service() {
|
||||
if ! is_installed; then
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
log_error "未检测到 systemd。无法重启服务。"
|
||||
return
|
||||
fi
|
||||
log_step "重启 Komari 服务..."
|
||||
systemctl restart ${SERVICE_NAME}.service
|
||||
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
|
||||
log_success "服务重启成功"
|
||||
else
|
||||
log_error "服务重启失败"
|
||||
fi
|
||||
}
|
||||
|
||||
# Stop service
|
||||
stop_service() {
|
||||
if ! is_installed; then
|
||||
log_error "Komari 未安装"
|
||||
return
|
||||
fi
|
||||
if ! check_systemd; then
|
||||
log_error "未检测到 systemd。无法停止服务。"
|
||||
return
|
||||
fi
|
||||
log_step "停止 Komari 服务..."
|
||||
systemctl stop ${SERVICE_NAME}.service
|
||||
log_success "服务已停止"
|
||||
}
|
||||
|
||||
|
||||
# Main menu
|
||||
main_menu() {
|
||||
show_banner
|
||||
echo "请选择操作:"
|
||||
echo " 1) 安装 Komari"
|
||||
echo " 2) 升级 Komari"
|
||||
echo " 3) 卸载 Komari"
|
||||
echo " 4) 查看状态"
|
||||
echo " 5) 查看日志"
|
||||
echo " 6) 重启服务"
|
||||
echo " 7) 停止服务"
|
||||
echo " 8) 退出"
|
||||
echo
|
||||
|
||||
read -p "输入选项 [1-8]: " choice
|
||||
|
||||
case $choice in
|
||||
1) install_binary ;;
|
||||
2) upgrade_komari ;;
|
||||
3) uninstall_komari ;;
|
||||
4) show_status ;;
|
||||
5) show_logs ;;
|
||||
6) restart_service ;;
|
||||
7) stop_service ;;
|
||||
8) exit 0 ;;
|
||||
*) log_error "无效选项" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main execution
|
||||
check_root
|
||||
main_menu
|
||||
+6
-1
@@ -113,7 +113,12 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Status(200)
|
||||
|
||||
c.Writer.WriteString(IndexFile)
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/admin") {
|
||||
c.Writer.WriteString(RawIndexFile)
|
||||
} else {
|
||||
c.Writer.WriteString(IndexFile)
|
||||
}
|
||||
|
||||
c.Writer.Flush()
|
||||
c.Writer.WriteHeaderNow()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func downloadCloudflared(downloadURL, filePath string) error {
|
||||
log.Println("downloading cloudflared...")
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
closeErr := out.Close() // 先关闭文件
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
err = os.Chmod(filePath, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var cloudflaredCmd *exec.Cmd
|
||||
|
||||
func RunCloudflared() error {
|
||||
var (
|
||||
downloadURL string
|
||||
fileName = "cloudflared"
|
||||
)
|
||||
if _, err := os.Stat("data"); os.IsNotExist(err) {
|
||||
os.Mkdir("data", 0755)
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
fileName = "cloudflared.exe"
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
|
||||
case "386":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
|
||||
case "386":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-386"
|
||||
case "arm":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm"
|
||||
case "arm64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
case "darwin":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
|
||||
case "arm64":
|
||||
downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
|
||||
default:
|
||||
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported os: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
filePath := "data/" + fileName
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
err := downloadCloudflared(downloadURL, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"tunnel", "--no-autoupdate", "run", "--token"}
|
||||
token := os.Getenv("KOMARI_CLOUDFLARED_TOKEN")
|
||||
args = append(args, token)
|
||||
|
||||
cmd := exec.Command(filePath, args...)
|
||||
cloudflaredCmd = cmd
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
log.Printf("[cloudflared] %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
log.Printf("[cloudflared] %s", scanner.Text())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Printf("cloudflared exited with error: %v", err)
|
||||
} else {
|
||||
log.Println("cloudflared exited successfully")
|
||||
}
|
||||
os.Exit(1)
|
||||
}()
|
||||
log.Println("cloudflared started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func Kill() {
|
||||
if cloudflaredCmd != nil && cloudflaredCmd.Process != nil {
|
||||
err := cloudflaredCmd.Process.Kill()
|
||||
if err != nil {
|
||||
log.Printf("failed to kill cloudflared: %v", err)
|
||||
} else {
|
||||
log.Println("cloudflared killed")
|
||||
}
|
||||
cloudflaredCmd = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cloudflared_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
)
|
||||
|
||||
func TestRunCloudflared(t *testing.T) {
|
||||
os.Setenv("KOMARI_CLOUDFLARED_TOKEN", "test-token")
|
||||
|
||||
err := cloudflared.RunCloudflared()
|
||||
if err != nil {
|
||||
t.Fatalf("RunCloudflared failed: %v", err)
|
||||
}
|
||||
|
||||
// 等待一段时间,确保子进程已启动
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
|
||||
type EmptyProvider struct{}
|
||||
|
||||
func (e *EmptyProvider) Name() string {
|
||||
return "EmptyProvider"
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
var CurrentProvider GeoIPService
|
||||
var geoCache *cache.Cache
|
||||
|
||||
type GeoInfo struct {
|
||||
ISOCode string
|
||||
@@ -18,11 +21,14 @@ type GeoInfo struct {
|
||||
|
||||
func init() {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
geoCache = cache.New(48*time.Hour, 1*time.Hour)
|
||||
}
|
||||
|
||||
// GeoIPService 接口定义了获取地理位置信息的核心方法。
|
||||
// 任何实现此接口的类型都可以作为地理位置服务提供者。
|
||||
type GeoIPService interface {
|
||||
Name() string
|
||||
|
||||
GetGeoInfo(ip net.IP) (*GeoInfo, error)
|
||||
|
||||
UpdateDatabase() error
|
||||
@@ -105,3 +111,29 @@ func InitGeoIp() {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
}
|
||||
}
|
||||
|
||||
func GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
providerName := CurrentProvider.Name()
|
||||
cacheKey := providerName + ":" + ip.String()
|
||||
|
||||
if cachedInfo, found := geoCache.Get(cacheKey); found {
|
||||
//log.Println("GeoIP cache hit for", cacheKey)
|
||||
return cachedInfo.(*GeoInfo), nil
|
||||
}
|
||||
|
||||
info, err := CurrentProvider.GetGeoInfo(ip)
|
||||
if err == nil && info != nil {
|
||||
//log.Println("GeoIP cache miss for", cacheKey)
|
||||
geoCache.Set(cacheKey, info, cache.DefaultExpiration)
|
||||
}
|
||||
return info, err
|
||||
}
|
||||
|
||||
func UpdateDatabase() error {
|
||||
err := CurrentProvider.UpdateDatabase()
|
||||
if err == nil {
|
||||
geoCache.Flush()
|
||||
log.Println("GeoIP cache cleared due to database update.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func testIpAddr(t *testing.T) {
|
||||
// IPv4
|
||||
ipaddr := "8.8.8.8"
|
||||
ip := net.ParseIP(ipaddr)
|
||||
record, err := geoip.CurrentProvider.GetGeoInfo(ip)
|
||||
record, err := geoip.GetGeoInfo(ip)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get GeoIP info for IP %s: %v", ipaddr, err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func testIpAddr(t *testing.T) {
|
||||
// IPv6
|
||||
ipaddr = "2001:4860:4860::8888"
|
||||
ip = net.ParseIP(ipaddr)
|
||||
record, err = geoip.CurrentProvider.GetGeoInfo(ip)
|
||||
record, err = geoip.GetGeoInfo(ip)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get GeoIP info for IPv6 %s: %v", ipaddr, err)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func NewGeoJSService() (*GeoJSService, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name 返回服务的名称。
|
||||
func (s *GeoJSService) Name() string {
|
||||
return "geojs.io"
|
||||
}
|
||||
|
||||
// GetGeoInfo 使用 geojs.io 服务检索给定 IP 地址的地理位置信息。
|
||||
func (s *GeoJSService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
// GeoJS 的 API 端点
|
||||
|
||||
@@ -32,6 +32,10 @@ type ipAPIResponse struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func (s *IPAPIService) Name() string {
|
||||
return "ip-api.com"
|
||||
}
|
||||
|
||||
// NewIPAPIService 创建并返回一个 IPAPIService 的新实例。
|
||||
func NewIPAPIService() (*IPAPIService, error) {
|
||||
return &IPAPIService{
|
||||
|
||||
@@ -38,6 +38,11 @@ func NewIPInfoService() (*IPInfoService, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name 返回服务的名称。
|
||||
func (s *IPInfoService) Name() string {
|
||||
return "ipinfo.io"
|
||||
}
|
||||
|
||||
// GetGeoInfo 使用 ipinfo.io 服务检索给定 IP 地址的地理位置信息。
|
||||
// 免费额度主要提供国家信息。
|
||||
func (s *IPInfoService) GetGeoInfo(ip net.IP) (*GeoInfo, error) {
|
||||
|
||||
@@ -40,6 +40,11 @@ type MaxMindGeoIPService struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Name 返回服务的名称。
|
||||
func (s *MaxMindGeoIPService) Name() string {
|
||||
return "MaxMind"
|
||||
}
|
||||
|
||||
// NewMaxMindGeoIPService 创建并返回一个 MaxMindGeoIPService 实例。
|
||||
// 它负责初始化服务,包括尝试加载或下载数据库。
|
||||
func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package messageSender
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/logOperation"
|
||||
)
|
||||
|
||||
var CurrentProvider MessageSender
|
||||
@@ -34,5 +35,14 @@ type MessageSender interface {
|
||||
}
|
||||
|
||||
func SendTextMessage(message string, title string) error {
|
||||
return CurrentProvider.SendTextMessage(message, title)
|
||||
var err error
|
||||
for i := 0; i < 3; i++ {
|
||||
err = CurrentProvider.SendTextMessage(message, title)
|
||||
if err == nil {
|
||||
logOperation.Log("", "", "Message sent: "+title, "info")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
logOperation.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
|
||||
return err
|
||||
}
|
||||
@@ -21,52 +21,44 @@ func (t *TelegramMessageSender) SendTextMessage(message, title string) error {
|
||||
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < 3; i++ { // Retry mechanism
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if fullMessage == "" {
|
||||
return errors.New("telegram is disabled or message is empty") // Assuming disabled if message is empty
|
||||
}
|
||||
|
||||
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", conf.TelegramChatID)
|
||||
data.Set("text", fullMessage)
|
||||
data.Set("parse_mode", "HTML") // Use HTML for basic formatting if title is present
|
||||
|
||||
resp, err := http.PostForm(endpoint, data)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("failed to send message: %v", err)
|
||||
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
|
||||
var err error
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
if fullMessage == "" {
|
||||
return errors.New("message is empty")
|
||||
}
|
||||
|
||||
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", conf.TelegramChatID)
|
||||
data.Set("text", fullMessage)
|
||||
data.Set("parse_mode", "HTML")
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// LoadNotificationService 管理定时器和任务
|
||||
type LoadNotificationService struct {
|
||||
mu sync.Mutex
|
||||
tickers map[int]*time.Ticker
|
||||
tasks map[int][]models.LoadNotification
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
var LoadNotificationManager = &LoadNotificationService{
|
||||
tickers: make(map[int]*time.Ticker),
|
||||
tasks: make(map[int][]models.LoadNotification),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Reload 重载时间表
|
||||
func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotification) 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.LoadNotification)
|
||||
|
||||
// 按Interval分组任务
|
||||
taskGroups := make(map[int][]models.LoadNotification)
|
||||
for _, task := range loadNotifications {
|
||||
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
|
||||
}
|
||||
|
||||
// 为每个唯一的Interval创建定时器
|
||||
for interval, tasks := range taskGroups {
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
|
||||
m.tickers[interval] = ticker
|
||||
m.tasks[interval] = tasks
|
||||
|
||||
go func(ticker *time.Ticker, tasks []models.LoadNotification) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for _, task := range tasks {
|
||||
go executeLoadNotificationTask(task)
|
||||
}
|
||||
case <-m.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}(ticker, tasks)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeLoadNotificationTask 执行单个LoadNotificationTask
|
||||
func executeLoadNotificationTask(task models.LoadNotification) {
|
||||
// 检查是否在冷却期内
|
||||
if shouldSkipNotification(task) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-time.Duration(task.Interval) * time.Minute)
|
||||
overloadClients := make([]string, 0)
|
||||
for _, clientUUID := range task.Clients {
|
||||
// 获取客户端在时间窗口内的记录
|
||||
records, err := getRecordsForClient(clientUUID, windowStart, now)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查指标是否达到阈值
|
||||
if checkMetricThreshold(records, task) {
|
||||
overloadClients = append(overloadClients, clientUUID)
|
||||
}
|
||||
|
||||
}
|
||||
sendLoadNotification(overloadClients, task)
|
||||
updateLastNotified(task.Id, now)
|
||||
}
|
||||
|
||||
// shouldSkipNotification 检查是否应该跳过通知(冷却期检查)
|
||||
func shouldSkipNotification(task models.LoadNotification) bool {
|
||||
if task.LastNotified.ToTime().IsZero() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 计算冷却期(使用 interval 作为冷却期)
|
||||
cooldownPeriod := time.Duration(task.Interval) * time.Minute
|
||||
timeSinceLastNotified := time.Since(task.LastNotified.ToTime())
|
||||
|
||||
return timeSinceLastNotified < cooldownPeriod
|
||||
}
|
||||
|
||||
// getRecordsForClient 获取指定客户端在时间窗口内的记录
|
||||
func getRecordsForClient(clientUUID string, start, end time.Time) ([]models.Record, error) {
|
||||
return records.GetRecordsByClientAndTime(clientUUID, start, end)
|
||||
}
|
||||
|
||||
// checkMetricThreshold 检查指标是否达到阈值
|
||||
func checkMetricThreshold(records []models.Record, task models.LoadNotification) bool {
|
||||
if len(records) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 计算需要达标的最小记录数
|
||||
minRequiredRecords := int(float32(len(records)) * task.Ratio)
|
||||
if minRequiredRecords == 0 {
|
||||
minRequiredRecords = 1
|
||||
}
|
||||
|
||||
exceededCount := 0
|
||||
|
||||
for _, record := range records {
|
||||
metricValue := getMetricValue(record, task.Metric)
|
||||
if metricValue >= task.Threshold {
|
||||
exceededCount++
|
||||
}
|
||||
}
|
||||
|
||||
return exceededCount >= minRequiredRecords
|
||||
}
|
||||
|
||||
// getMetricValue 根据指标名称获取记录中的对应值
|
||||
func getMetricValue(record models.Record, metric string) float32 {
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
switch metric {
|
||||
case "cpu":
|
||||
return record.Cpu
|
||||
case "gpu":
|
||||
return record.Gpu
|
||||
case "ram":
|
||||
if record.RamTotal > 0 {
|
||||
return float32(record.Ram) / float32(client.MemTotal) * 100
|
||||
}
|
||||
return 0
|
||||
case "swap":
|
||||
if record.SwapTotal > 0 {
|
||||
return float32(record.Swap) / float32(client.SwapTotal) * 100
|
||||
}
|
||||
return 0
|
||||
case "load":
|
||||
return record.Load
|
||||
case "temp":
|
||||
return record.Temp
|
||||
case "disk":
|
||||
if record.DiskTotal > 0 {
|
||||
return float32(record.Disk) / float32(client.DiskTotal) * 100
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
// 尝试通过反射获取字段值
|
||||
v := reflect.ValueOf(record)
|
||||
field := v.FieldByName(metric)
|
||||
if field.IsValid() && field.CanInterface() {
|
||||
switch field.Kind() {
|
||||
case reflect.Float32:
|
||||
return float32(field.Float())
|
||||
case reflect.Float64:
|
||||
return float32(field.Float())
|
||||
case reflect.Int, reflect.Int32, reflect.Int64:
|
||||
return float32(field.Int())
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// sendLoadNotification 发送负载通知
|
||||
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
|
||||
message := "The following clients have exceeded the load threshold:\n\n"
|
||||
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
|
||||
}
|
||||
message += fmt.Sprintf("%s\n", client.Name)
|
||||
}
|
||||
if len(clientUUIDs) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
messageSender.SendTextMessage(message, fmt.Sprintf("%s - Komari Load Alert", task.Name))
|
||||
}()
|
||||
}
|
||||
|
||||
// updateLastNotified 更新最后通知时间
|
||||
func updateLastNotified(taskId uint, notifyTime time.Time) {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Model(&models.LoadNotification{}).Where("id = ?", taskId).Update("last_notified", notifyTime).Error; err != nil {
|
||||
log.Printf("Failed to update last_notified for task %d: %v", taskId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadLoadNotificationSchedule 加载或重载时间表
|
||||
func ReloadLoadNotificationSchedule(loadNotifications []models.LoadNotification) error {
|
||||
return LoadNotificationManager.Reload(loadNotifications)
|
||||
}
|
||||
+99
-38
@@ -1,59 +1,120 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func AverageReport(uuid string, time time.Time, records []common.Report) models.Record {
|
||||
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
|
||||
// AverageReport 根据 topPercentage 参数计算报告的平均值。
|
||||
// 如果 topPercentage 为 0,则计算所有记录的平均值。
|
||||
// 如果 topPercentage 大于 0 且小于等于 1,则计算从大到小排序后的前 topPercentage 记录的平均值。
|
||||
func AverageReport(uuid string, time time.Time, records []common.Report, topPercentage float64) models.Record {
|
||||
count := len(records)
|
||||
if count == 0 {
|
||||
return models.Record{}
|
||||
}
|
||||
for _, r := range records {
|
||||
sumCPU += float32(r.CPU.Usage)
|
||||
sumGPU += 0
|
||||
sumLOAD += float32(r.Load.Load1)
|
||||
sumTEMP += 0
|
||||
sumRAM += r.Ram.Used
|
||||
sumSWAP += r.Swap.Used
|
||||
sumDISK += r.Disk.Used
|
||||
sumNETIn += r.Network.Down
|
||||
sumNETOut += r.Network.Up
|
||||
sumNETTotalUp += r.Network.TotalUp
|
||||
sumNETTotalDown += r.Network.TotalDown
|
||||
sumPROCESS += r.Process
|
||||
sumConnections += r.Connections.TCP
|
||||
sumConnectionsUDP += r.Connections.UDP
|
||||
|
||||
recordsToAverageCount := count
|
||||
if topPercentage > 0 && topPercentage <= 1 {
|
||||
recordsToAverageCount = int(float64(count) * topPercentage)
|
||||
if recordsToAverageCount == 0 && count > 0 {
|
||||
recordsToAverageCount = 1 // 确保至少选择一个记录,除非总数为0
|
||||
}
|
||||
}
|
||||
|
||||
// Create new aggregated record
|
||||
// 定义一个辅助函数,用于排序和求和
|
||||
sumAndSort := func(getFloat32Value func(common.Report) float32, getInt64Value func(common.Report) int64, isFloat bool) (float32, int64) {
|
||||
if isFloat {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return getFloat32Value(records[i]) > getFloat32Value(records[j])
|
||||
})
|
||||
var sum float32
|
||||
for i := 0; i < recordsToAverageCount; i++ {
|
||||
sum += getFloat32Value(records[i])
|
||||
}
|
||||
return sum, 0
|
||||
} else {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return getInt64Value(records[i]) > getInt64Value(records[j])
|
||||
})
|
||||
var sum int64
|
||||
for i := 0; i < recordsToAverageCount; i++ {
|
||||
sum += getInt64Value(records[i])
|
||||
}
|
||||
return 0, sum
|
||||
}
|
||||
}
|
||||
|
||||
var sumCPU, sumLOAD 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)
|
||||
|
||||
_, 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)
|
||||
_, sumSWAP = sumAndSort(nil, func(r common.Report) int64 { return r.Swap.Used }, false)
|
||||
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Swap.Total }, false)
|
||||
_, sumDISK = sumAndSort(nil, func(r common.Report) int64 { return r.Disk.Used }, false)
|
||||
//_, sumDISKTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Disk.Total }, false)
|
||||
_, sumNETIn = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Down }, false)
|
||||
_, sumNETOut = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Up }, false)
|
||||
_, sumNETTotalUp = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalUp }, false)
|
||||
_, sumNETTotalDown = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalDown }, false)
|
||||
|
||||
_, sumInt := sumAndSort(nil, func(r common.Report) int64 { return int64(r.Process) }, false)
|
||||
sumPROCESS = int(sumInt)
|
||||
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.TCP) }, false)
|
||||
sumConnections = int(sumInt)
|
||||
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.UDP) }, false)
|
||||
sumConnectionsUDP = int(sumInt)
|
||||
|
||||
} else { // 计算所有记录的平均值
|
||||
for _, r := range records {
|
||||
sumCPU += float32(r.CPU.Usage)
|
||||
sumLOAD += float32(r.Load.Load1)
|
||||
sumRAM += r.Ram.Used
|
||||
sumRAMTotal += r.Ram.Total
|
||||
sumSWAP += r.Swap.Used
|
||||
sumSWAPTotal += r.Swap.Total
|
||||
sumDISK += r.Disk.Used
|
||||
sumDISKTotal += r.Disk.Total
|
||||
sumNETIn += r.Network.Down
|
||||
sumNETOut += r.Network.Up
|
||||
sumNETTotalUp += r.Network.TotalUp
|
||||
sumNETTotalDown += r.Network.TotalDown
|
||||
sumPROCESS += r.Process
|
||||
sumConnections += r.Connections.TCP
|
||||
sumConnectionsUDP += r.Connections.UDP
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的聚合记录
|
||||
newRecord := models.Record{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(time),
|
||||
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,
|
||||
Cpu: sumCPU / float32(recordsToAverageCount),
|
||||
Gpu: 0, // 保持原始行为
|
||||
Ram: sumRAM / int64(recordsToAverageCount),
|
||||
RamTotal: records[0].Ram.Total,
|
||||
Swap: sumSWAP / int64(recordsToAverageCount),
|
||||
SwapTotal: records[0].Swap.Total,
|
||||
Load: sumLOAD / float32(recordsToAverageCount),
|
||||
Temp: 0, // 保持原始行为
|
||||
Disk: sumDISK / int64(recordsToAverageCount),
|
||||
DiskTotal: records[0].Disk.Total,
|
||||
NetIn: sumNETIn / int64(recordsToAverageCount),
|
||||
NetOut: sumNETOut / int64(recordsToAverageCount),
|
||||
NetTotalUp: sumNETTotalUp / int64(recordsToAverageCount),
|
||||
NetTotalDown: sumNETTotalDown / int64(recordsToAverageCount),
|
||||
Process: sumPROCESS / recordsToAverageCount,
|
||||
Connections: sumConnections / recordsToAverageCount,
|
||||
ConnectionsUdp: sumConnectionsUDP / recordsToAverageCount,
|
||||
}
|
||||
return newRecord
|
||||
}
|
||||
Reference in new issue
Block a user