mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 17:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7dd816a26 | ||
|
|
c5b464c000 | ||
|
|
645c733393 | ||
|
|
85088e64ca | ||
|
|
896b3e45fc | ||
|
|
490f951b59 | ||
|
|
69c96255d7 | ||
|
|
487542e55d | ||
|
|
ca8fe76c06 | ||
|
|
e6e3619cd2 | ||
|
|
92ab67d5f3 | ||
|
|
77f5bef1a5 | ||
|
|
58e43a0862 | ||
|
|
80c1f2747a | ||
|
|
b7b2d8b050 | ||
|
|
b9eabd7cdc | ||
|
|
0a19465140 |
No files matched your search
@@ -25,6 +25,10 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
|
||||
[](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/)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -22,6 +22,10 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
|
||||
[](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/)
|
||||
|
||||
@@ -22,6 +22,10 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
|
||||
[](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/)
|
||||
|
||||
@@ -22,6 +22,10 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
|
||||
[](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/)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user