Compare commits

...
17 Commits
Author SHA1 Message Date
Akizon d7dd816a26 Merge pull request #478 from Real-Wanger/main
fix: reset traffic cache when traffic drops
2026-03-04 14:17:34 +08:00
Wanger c5b464c000 fix: reset traffic cache when traffic drops
修复当节点流量被清零重置时,内存中的告警进度缓存未同步清理,导致无法触发后续阈值通知的 Bug。
2026-03-02 14:27:42 +08:00
Akizon 645c733393 Merge pull request #468 from wayjam/main
feat: import theme api #467
2026-02-26 19:46:09 +08:00
Akizon 85088e64ca Merge pull request #471 from vhvy/feat/keepalive-via-post
feat: 支持 HTTP POST 上报实时信息和在线状态管理,添加 HTTP 接口获取 Ping 任务和提交 Ping 结果
2026-02-26 19:40:05 +08:00
Akizon77 896b3e45fc fix: 添加 base_scripts_url 字段到 Legacy 结构体 #472 2026-02-26 19:04:18 +08:00
vhvy 490f951b59 feat: 添加 HTTP 接口获取 Ping 任务和提交 Ping 结果 2026-02-23 21:27:03 +08:00
vhvy 69c96255d7 feat: 支持 HTTP POST 上报实时信息保活及在线状态管理 2026-02-23 21:24:14 +08:00
WayJam So 487542e55d feat: import theme api #467 2026-02-21 16:55:50 +08:00
Akizon ca8fe76c06 Merge pull request #463 from komari-monitor/refactor/增强临时分享的兼容性
refactor: 增强临时分享的兼容性
2026-02-19 00:15:29 +08:00
Akizon77 e6e3619cd2 refactor: 增强临时分享的兼容性 2026-02-19 00:14:23 +08:00
Akizon77 92ab67d5f3 feat: 添加临时访问许可功能,允许使用临时密钥访问私有站点 2026-02-16 00:34:43 +08:00
Akizon77 77f5bef1a5 fix: 配置默认值 2026-02-15 14:17:03 +08:00
Akizon77 58e43a0862 fix: 增强 WebSocket 升级错误消息,包含详细错误信息 2026-02-12 13:41:01 +08:00
Akizon 80c1f2747a Merge pull request #455 from komari-monitor/fix/expire-notification-panic
fix: 修复过期通知的通知提前天数类型转换错误
2026-02-12 13:13:12 +08:00
Akizon77 b7b2d8b050 fix: 修复过期通知的通知提前天数类型转换错误 2026-02-12 13:12:16 +08:00
Akizon77 b9eabd7cdc docs: 添加 1Panel 应用商店的安装说明 2026-02-10 22:54:48 +08:00
Akizon77 0a19465140 docs: 添加破碎工坊云的介绍和链接 2026-02-09 23:08:57 +08:00
22 changed files with 722 additions and 107 deletions

No files matched your search

+8
View File
@@ -25,6 +25,10 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel App Store
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
@@ -113,6 +117,10 @@ Issues and Pull Requests are welcome!
## Acknowledgements
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
+31
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"time"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
@@ -56,6 +57,36 @@ func PrivateSiteMiddleware() gin.HandlerFunc {
c.Next()
return
}
// 临时访问许可
if func() bool {
tempKey, err := c.Cookie("temp_key")
if err != nil {
return false
}
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return false
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return false
}
if allowTempKey == "" || tempKey != allowTempKey {
return false
}
now := time.Now().Unix()
if tempKeyExpireTime < now {
return false
}
return true
}() {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
+9 -8
View File
@@ -59,31 +59,32 @@ func TokenAuthMiddleware() gin.HandlerFunc {
return
}
exists, err := checkTokenExists(token)
uuid, err := checkTokenAndGetUUID(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
return
}
if !exists {
if uuid == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
return
}
c.Set("client_uuid", uuid)
c.Next()
}
}
func checkTokenExists(token string) (bool, error) {
_, err := clients.GetClientUUIDByToken(token)
func checkTokenAndGetUUID(token string) (string, error) {
uuid, err := clients.GetClientUUIDByToken(token)
if err == sql.ErrNoRows {
return false, nil
return "", nil
}
if err == gorm.ErrRecordNotFound {
return false, nil
return "", nil
}
if err != nil {
return false, err
return "", err
}
return true, nil
return uuid, nil
}
-9
View File
@@ -45,15 +45,6 @@ func EditSettings(c *gin.Context) {
api.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
+142
View File
@@ -594,6 +594,148 @@ func UpdateTheme(c *gin.Context) {
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
// peekThemeFromZip 仅从ZIP文件中读取komari-theme.json并解析主题信息
// 不执行解压安装,用于preview模式
func peekThemeFromZip(zipPath string) (models.Theme, error) {
var themeInfo models.Theme
r, err := zip.OpenReader(zipPath)
if err != nil {
return themeInfo, fmt.Errorf("无法打开ZIP文件: %v", err)
}
defer r.Close()
var themeConfigFile *zip.File
for _, f := range r.File {
if f.Name == "komari-theme.json" {
themeConfigFile = f
break
}
}
if themeConfigFile == nil {
return themeInfo, fmt.Errorf("主题配置文件 komari-theme.json 不存在,不是合法的主题包")
}
rc, err := themeConfigFile.Open()
if err != nil {
return themeInfo, fmt.Errorf("无法读取主题配置文件: %v", err)
}
defer rc.Close()
configData, err := io.ReadAll(rc)
if err != nil {
return themeInfo, fmt.Errorf("读取主题配置失败: %v", err)
}
if err := json.Unmarshal(configData, &themeInfo); err != nil {
return themeInfo, fmt.Errorf("主题配置格式错误: %v", err)
}
if themeInfo.Name == "" || themeInfo.Short == "" {
return themeInfo, fmt.Errorf("主题配置缺少必填字段(name、short)")
}
if !isValidThemeShort(themeInfo.Short) {
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
}
return themeInfo, nil
}
// ImportTheme 导入远程主题
// 支持preview查询参数:preview=true时仅返回主题信息,否则下载安装
// 请求body: {"url": "https://..."}
// URL支持GitHub仓库地址(自动取latest release)和直接ZIP下载链接
func ImportTheme(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 解析下载链接
downloadURL := req.URL
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
if isGitHub {
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
downloadURL = gitHubURL
}
// 下载主题ZIP
themeData, err := downloadThemeFromURL(downloadURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "下载主题失败: "+err.Error())
return
}
// 保存到临时文件
tempFile := filepath.Join(os.TempDir(), "import_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// preview模式:仅解析并返回主题信息
preview := c.Query("preview")
if preview == "true" {
themeInfo, err := peekThemeFromZip(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
// 检查是否已存在同名主题
exists := false
themeDir := filepath.Join("./data/theme", themeInfo.Short)
if _, err := os.Stat(themeDir); err == nil {
exists = true
}
api.RespondSuccess(c, gin.H{
"theme": themeInfo,
"exists": exists,
})
return
}
// 安装模式:检查是否存在同名主题
// 先peek一下获取short名称用于检测冲突
themeInfo, err := peekThemeFromZip(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
overwritten := false
themeDir := filepath.Join("./data/theme", themeInfo.Short)
if _, err := os.Stat(themeDir); err == nil {
overwritten = true
}
// 解压安装
installedTheme, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
msg := "主题导入成功"
if overwritten {
msg = "主题导入成功(已覆盖同名主题)"
}
api.RespondSuccessMessage(c, msg, installedTheme)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" {
+55
View File
@@ -0,0 +1,55 @@
package client
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
func GetPingTasks(c *gin.Context) {
uuid, _ := c.Get("client_uuid")
clientUUID, _ := uuid.(string)
if clientUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
return
}
result := tasks.GetPingTasksByClient(clientUUID)
c.JSON(http.StatusOK, result)
}
func UploadPingResult(c *gin.Context) {
uuid, _ := c.Get("client_uuid")
clientUUID, _ := uuid.(string)
if clientUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_uuid not found"})
return
}
var req struct {
TaskID uint `json:"task_id" binding:"required"`
Value int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
record := models.PingRecord{
Client: clientUUID,
TaskId: req.TaskID,
Value: req.Value,
Time: models.FromTime(req.FinishedAt),
}
if err := tasks.SavePingRecord(record); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save ping result: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
+88 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"sync"
"time"
"log"
@@ -28,6 +29,73 @@ const (
readWait = 11 * time.Second
)
// postPresenceEntry 保存单个客户端的 POST 上报会话状态
type postPresenceEntry struct {
connID int64
timer *time.Timer
generation uint64 // 每次 Reset 递增,用于回调中判断是否为过期的旧回调
}
var (
postPresenceMu sync.Mutex
postPresenceStates = make(map[string]*postPresenceEntry)
)
// refreshPostPresence 管理 HTTP POST 上报者的在线/离线状态。
// 每次 POST 刷新 TTL 定时器;定时器到期后触发离线通知。
func refreshPostPresence(uuid string) {
postPresenceMu.Lock()
defer postPresenceMu.Unlock()
if entry, exists := postPresenceStates[uuid]; exists {
// 已在线:递增 generation 使可能正在执行的旧回调失效
entry.generation++
entry.timer.Stop()
// 重新创建 AfterFunc 以在闭包中捕获新的 generation
gen := entry.generation
entry.timer = time.AfterFunc(readWait, func() {
postPresenceExpired(uuid, entry.connID, gen)
})
ws.KeepAlivePresence(uuid, entry.connID, readWait)
return
}
// 新 POST 会话:生成 connID,标记在线,启动超时定时器
connID := time.Now().UnixNano()
ws.KeepAlivePresence(uuid, connID, readWait)
go notifier.OnlineNotification(uuid, connID)
defaultGeneration := uint64(0)
entry := &postPresenceEntry{
connID: connID,
generation: defaultGeneration,
}
entry.timer = time.AfterFunc(readWait, func() {
postPresenceExpired(uuid, connID, defaultGeneration)
})
postPresenceStates[uuid] = entry
}
// postPresenceExpired 是定时器到期的回调。
// 只有当 connID 和 generation 都与当前 entry 匹配时才执行离线清理,
// 避免 timer.Reset 竞态导致过期回调错误地清除仍活跃的会话。
func postPresenceExpired(uuid string, connID int64, gen uint64) {
postPresenceMu.Lock()
e, ok := postPresenceStates[uuid]
if !ok || e.connID != connID || e.generation != gen {
postPresenceMu.Unlock()
return
}
delete(postPresenceStates, uuid)
postPresenceMu.Unlock()
ws.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
@@ -50,14 +118,31 @@ func UploadReport(c *gin.Context) {
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
// 优先使用 body 中的 UUID,若为空则从中间件注入的上下文中获取
uuid := report.UUID
if uuid == "" {
if v, ok := c.Get("client_uuid"); ok {
uuid, _ = v.(string)
}
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "UUID is required"})
return
}
report.UUID = uuid
err = SaveClientReport(uuid, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
ws.SetLatestReport(report.UUID, &report)
ws.SetLatestReport(uuid, &report)
// POST 上报后标记节点在线,超时未收到新 POST 则触发离线
refreshPostPresence(uuid)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -77,7 +162,7 @@ func WebSocketReport(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
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"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
conn := ws.NewSafeConn(unsafeConn)
+1 -1
View File
@@ -75,7 +75,7 @@ func OnRpcRequest(c *gin.Context) {
})
conn := ws.NewSafeConn(_conn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
permissionGroup := detectPermissionGroup(c)
+3 -3
View File
@@ -289,9 +289,9 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
meta := rpc.MetaFromContext(ctx)
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
connected := ws.GetConnectedClients()
onlineSet := make(map[string]bool, len(connected))
for uuid := range connected {
onlineUUIDs := ws.GetAllOnlineUUIDs()
onlineSet := make(map[string]bool, len(onlineUUIDs))
for _, uuid := range onlineUUIDs {
onlineSet[uuid] = true
}
+31
View File
@@ -1,7 +1,10 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
)
@@ -11,5 +14,33 @@ func GetPublicSettings(c *gin.Context) {
RespondError(c, 500, e.Error())
return
}
// 临时访问许可
if func() bool {
tempKey, err := c.Cookie("temp_key")
if err != nil {
return false
}
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return false
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return false
}
if allowTempKey == "" || tempKey != allowTempKey {
return false
}
now := time.Now().Unix()
if tempKeyExpireTime < now {
return false
}
return true
}() {
p["private_site"] = false
}
RespondSuccess(c, p)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func GetClients(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
conn, 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"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
defer conn.Close()
+3 -4
View File
@@ -68,10 +68,6 @@ func init() {
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 创建主题目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
@@ -214,6 +210,8 @@ func RunServer() {
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
tokenAuthrized.GET("/ping/tasks", client.GetPingTasks)
tokenAuthrized.POST("/ping/result", client.UploadPingResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
@@ -262,6 +260,7 @@ func RunServer() {
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/import", admin.ImportTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
+240 -23
View File
@@ -103,7 +103,7 @@ func Get(key string, defaul ...any) (any, error) {
return val, nil
}
// GetAs 获取并转换为指定类型 (泛型)
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
func GetAs[T any](key string, defaul ...any) (T, error) {
var t T
var item ConfigItem
@@ -111,21 +111,40 @@ func GetAs[T any](key string, defaul ...any) (T, error) {
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v, ok := defaul[0].(T)
if !ok {
// 尝试直接类型断言
if v, ok := defaul[0].(T); ok {
err = Set(key, v)
return v, err
}
// 尝试类型转换
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(defaul[0], val); err != nil {
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
}
err = Set(key, v)
return v, err
err = Set(key, t)
return t, err
}
return t, err
}
err = json.Unmarshal([]byte(item.Value), &t)
return t, err
// 先尝试直接反序列化
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
return t, err
}
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(generic, val); err != nil {
return t, err
}
}
return t, nil
}
// key[defaults]
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
// 如果 defaultValue 为 nil,则数据库不存在时不写入
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
func GetMany(keys map[string]any) (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
@@ -149,35 +168,81 @@ func GetMany(keys map[string]any) (map[string]any, error) {
}
}
// Fill in defaults for missing keys
// 收集需要写入数据库的默认值
var toInsert []ConfigItem
for k, def := range keys {
if _, found := foundKeys[k]; !found {
if def != nil {
result[k] = def
_ = Set(k, def) // Ignore error
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
slog.Warn("marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: k,
Value: string(jsonBytes),
})
}
}
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return result, nil
}
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
// 没有 default tag 的字段使用零值,不写入数据库
func GetManyAs[T any]() (*T, error) {
var t T
val := reflect.ValueOf(&t).Elem()
typ := val.Type()
tagToFieldMap := make(map[string]int) // Key -> FieldIndex
type fieldInfo struct {
index int
key string
hasDefault bool
defaultVal string
}
fields := make([]fieldInfo, 0)
keys := make([]string, 0)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
if tag != "" && tag != "-" {
keys = append(keys, tag)
tagToFieldMap[tag] = i
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// 解析 json tag,处理 "key,omitempty" 格式
key := strings.Split(jsonTag, ",")[0]
if key == "" || key == "-" {
continue
}
defaultTag := field.Tag.Get("default")
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
// 检查是否显式定义了 default tag (即使值为空)
_, hasDefault = field.Tag.Lookup("default")
fields = append(fields, fieldInfo{
index: i,
key: key,
hasDefault: hasDefault,
defaultVal: defaultTag,
})
keys = append(keys, key)
}
if len(keys) == 0 {
@@ -189,27 +254,179 @@ func GetManyAs[T any]() (*T, error) {
return nil, err
}
// 建立数据库中存在的 key 映射
foundItems := make(map[string]string) // key -> value
for _, item := range items {
fieldIndex, ok := tagToFieldMap[item.Key]
if !ok {
continue
}
foundItems[item.Key] = item.Value
}
fieldVal := val.Field(fieldIndex)
// 需要写入数据库的新配置项
var toInsert []ConfigItem
for _, fi := range fields {
fieldVal := val.Field(fi.index)
if !fieldVal.CanSet() {
continue
}
target := reflect.New(fieldVal.Type()).Interface()
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
slog.Warn("parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: fi.key,
Value: string(jsonBytes),
})
}
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
}
if err := json.Unmarshal([]byte(item.Value), target); err == nil {
fieldVal.Set(reflect.ValueOf(target).Elem())
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return &t, nil
}
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
return err
}
return convertAndSet(generic, fieldVal)
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
// parseDefaultToField 解析 default tag 值到字段
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
kind := fieldVal.Kind()
switch kind {
case reflect.String:
fieldVal.SetString(defaultVal)
case reflect.Bool:
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var v int64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
// 尝试解析浮点数后转换
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = int64(f)
}
}
fieldVal.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
var v uint64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = uint64(f)
}
}
fieldVal.SetUint(v)
case reflect.Float32, reflect.Float64:
var v float64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
return err
}
}
fieldVal.SetFloat(v)
default:
// 对于复杂类型,尝试 JSON 解析
if defaultVal == "" {
return nil // 保持零值
}
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
}
return nil
}
// convertAndSet 通用类型转换并设置字段值
func convertAndSet(val any, fieldVal reflect.Value) error {
if val == nil {
return nil
}
targetType := fieldVal.Type()
v := reflect.ValueOf(val)
// 直接类型匹配
if v.Type().AssignableTo(targetType) {
fieldVal.Set(v)
return nil
}
// 类型可转换
if v.Type().ConvertibleTo(targetType) {
fieldVal.Set(v.Convert(targetType))
return nil
}
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
if f, ok := val.(float64); ok {
switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fieldVal.SetInt(int64(f))
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fieldVal.SetUint(uint64(f))
return nil
case reflect.Float32, reflect.Float64:
fieldVal.SetFloat(f)
return nil
}
}
// JSON 回环转换
b, err := json.Marshal(val)
if err != nil {
return err
}
target := reflect.New(targetType).Interface()
if err := json.Unmarshal(b, target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
func GetAll() (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
+32 -30
View File
@@ -3,42 +3,43 @@ package config
import "time"
type Legacy struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
AllowCors bool `json:"allow_cors" default:"false"` // 是否允许跨域,默认 false
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
CustomHead string `json:"custom_head" default:""`
CustomBody string `json:"custom_body" default:""`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
NotificationMethod string `json:"notification_method" default:"none"`
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
UpdatedAt time.Time
}
@@ -53,6 +54,7 @@ const (
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
GeoIpProviderKey = "geo_ip_provider"
NezhaCompatEnabledKey = "nezha_compat_enabled"
+9
View File
@@ -56,6 +56,15 @@ func GetAllPingTasks() ([]models.PingTask, error) {
return tasks, nil
}
func GetPingTasksByClient(uuid string) []models.PingTask {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
return nil
}
return tasks
}
func SavePingRecord(record models.PingRecord) error {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
+2 -23
View File
@@ -13,8 +13,8 @@ import (
"github.com/komari-monitor/komari/database/models"
)
func GetPublicInfo() (any, error) {
cstPtr, err := config.GetManyAs[models.Config]()
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Legacy]()
if err != nil {
return nil, err
}
@@ -42,15 +42,6 @@ func GetPublicInfo() (any, error) {
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
if !hasKey("geo_ip_enabled") {
cst.GeoIpEnabled = true
}
if !hasKey("geo_ip_provider") {
cst.GeoIpProvider = "ip-api"
}
if !hasKey("notification_method") {
cst.NotificationMethod = "none"
}
if !hasKey("record_enabled") {
cst.RecordEnabled = true
}
@@ -69,18 +60,6 @@ func GetPublicInfo() (any, error) {
if cst.Description == "" {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
if cst.Theme == "" {
cst.Theme = "default"
}
if cst.OAuthProvider == "" {
cst.OAuthProvider = "github"
}
if cst.RecordPreserveTime == 0 {
cst.RecordPreserveTime = 720
}
if cst.PingRecordPreserveTime == 0 {
cst.PingRecordPreserveTime = 24
}
}
db := dbcore.GetDBInstance()
+8
View File
@@ -22,6 +22,10 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
@@ -112,6 +116,10 @@ IssueやPull Requestを歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
+8
View File
@@ -22,6 +22,10 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
@@ -112,6 +116,10 @@ sudo ./install-komari.sh
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
+8
View File
@@ -22,6 +22,10 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
@@ -112,6 +116,10 @@ sudo ./install-komari.sh
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
+36
View File
@@ -9,6 +9,7 @@ import (
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/config"
@@ -215,7 +216,42 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
c.Status(http.StatusNotFound)
return
}
//
func() {
tempKey := c.Query("temp_key")
if tempKey == "" {
return
}
tempKeyExpireTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return
}
if allowTempKey == "" || tempKey != allowTempKey {
return
}
now := time.Now().Unix()
if tempKeyExpireTime < now {
return
}
expireSeconds := int(tempKeyExpireTime - now)
if expireSeconds > 0 {
c.SetCookie(
"temp_key", // key
tempKey, // value
expireSeconds, // maxAge(秒)
"/", // path
"", // domain
false, // secure
false, // httpOnly
)
}
}()
reqPath := c.Request.URL.Path
cfg := getConfig()
currentTheme := cfg[config.ThemeKey].(string)
+1 -1
View File
@@ -42,7 +42,7 @@ func CheckExpireScheduledWork() {
// 过期提醒检查(仅当启用过期通知时)
if cfg[config.ExpireNotificationEnabledKey].(bool) {
notificationLeadDays := cfg[config.ExpireNotificationLeadDaysKey].(int)
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
+6 -1
View File
@@ -85,9 +85,14 @@ func CheckTraffic() {
last, _ := trafficCache.Get(key)
lastStep, _ := last.(int)
// 修复:当检测到当前进度小于历史记录时,说明流量已重置,将基准归零
if curStep < lastStep {
lastStep = 0
}
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
// 发送通知(内部会检查 NotificationEnabled)
_ = messageSender.SendEvent(models.EventMessage{