Compare commits

...
25 Commits
Author SHA1 Message Date
Akizon77 0dc80f7696 更新依赖项 2026-01-29 01:17:36 +08:00
Akizon77 212e3c689b feat: 添加网络流量指标支持 2026-01-29 01:05:49 +08:00
Akizon77 488ca451ae feat: RPC 内部请求,MJPEG流 2026-01-29 00:44:19 +08:00
Akizon77 f50c167b37 fix: 当您修改历史数据保存时间相关设置时,需要重新启动服务端才会生效 2026-01-29 00:33:29 +08:00
Akizon77 4e91d62d48 feat: 添加 WAL 检查点优化数据库性能 2025-11-14 16:44:48 +08:00
Akizon a1b0b1e177 Merge pull request #355 from komari-monitor/dev
docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issue…
2025-11-09 11:52:36 +08:00
Akizon77 147dd2011a docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issues/10 2025-11-09 11:43:05 +08:00
Akizon77 d24b71d4af docs: 移除 ClawRun - 云平台未更新维护且开发者无法更改内容 2025-11-04 23:16:14 +08:00
Akizon 483e3a94e9 remove Claw Run 2025-11-04 22:10:48 +08:00
Akizon e612f5ed8f Add badges for GitHub and DeepWiki to README 2025-11-04 21:38:45 +08:00
github-actions[bot] 78f3c8fd4b Merge pull request #350 from komari-monitor/dev
fix: #344 引入更宽松的认证方式
2025-11-04 06:50:01 +00:00
Akizon77 5a4e073d63 fix: #344 引入更宽松的认证方式 2025-11-04 14:49:34 +08:00
Akizon77 22cbfecfa9 feat: 整合日志功能 2025-11-04 14:36:40 +08:00
github-actions[bot] d1b059fc2f Merge pull request #349 from komari-monitor/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 05:02:09 +00:00
Akizon cf5c410aa6 Merge branch 'main' into dev 2025-11-04 13:01:53 +08:00
Akizon 9c46673a1a Merge pull request #348 from RSJWY/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 00:50:10 +08:00
RSJWY 45c27ae981 feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey) 2025-11-03 22:29:55 +08:00
akizon77 b62fb1f708 feat: 添加 JavaScript 消息发送器,支持 fetch 和 XMLHttpRequest API 2025-10-25 20:43:14 +08:00
github-actions[bot] 579c196643 Merge pull request #335 from komari-monitor/dev
feat: 添加 NOTICE 文件,包含版权和许可证信息
2025-10-18 09:16:09 +00:00
Akizon77 e8da6e213a feat: 添加 NOTICE 文件,包含版权和许可证信息 2025-10-18 17:15:20 +08:00
github-actions[bot] d9426e43a9 Merge pull request #333 from komari-monitor/dev
feat: 添加 EULA 接受字段到配置模型
2025-10-17 17:15:38 +00:00
Akizon77 aad0274d56 feat: 添加 EULA 接受字段到配置模型 2025-10-18 00:15:05 +08:00
github-actions[bot] d65f7ca924 Merge pull request #331 from komari-monitor/dev
fix: 重复注册Bark
2025-10-15 05:14:01 +00:00
Akizon77 a4d95640a5 fix: 重复注册Bark 2025-10-15 13:13:31 +08:00
Akizon77 adda3a2488 docs: 移除Crowdin 2025-10-15 13:09:18 +08:00
32 changed files with 3179 additions and 83 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
CoreOS Project
Copyright 2014 CoreOS, Inc
This product includes software developed at CoreOS, Inc.
(http://www.coreos.com/).
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
Copyright 2014 gRPC authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3 -6
View File
@@ -1,6 +1,8 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -8,7 +10,7 @@
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
@@ -22,9 +24,6 @@ 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_)
- Claw Run - USD 1.5/month
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
@@ -100,8 +99,6 @@ sudo ./install-komari.sh
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
+43
View File
@@ -19,6 +19,49 @@ import (
"github.com/komari-monitor/komari/ws"
)
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
conf, _ := config.Get()
if conf.PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if group == "client" || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
}
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
cfg, _ := config.Get()
+1624
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
public_api "github.com/komari-monitor/komari/api/public"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
@@ -37,6 +38,7 @@ import (
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
@@ -134,7 +136,9 @@ func RunServer() {
}
}
r := gin.Default()
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
// 动态 CORS 中间件
@@ -193,7 +197,7 @@ func RunServer() {
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
@@ -394,9 +398,9 @@ func DoScheduledWork() {
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
go notifier.CheckExpireScheduledWork()
for {
cfg, _ := config.Get()
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
+3 -9
View File
@@ -15,11 +15,10 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
@@ -408,13 +407,7 @@ func GetDBInstance() *gorm.DB {
}()
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
@@ -431,6 +424,7 @@ func GetDBInstance() *gorm.DB {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("VACUUM;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
+1
View File
@@ -11,6 +11,7 @@ type Config struct {
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
+18 -17
View File
@@ -41,9 +41,9 @@ func DeleteAll() error {
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
db := dbcore.GetDBInstance()
var records []models.GPURecord
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.GPURecord
recentStart := start
if end.After(fourHoursAgo) {
@@ -57,7 +57,7 @@ func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.G
return nil, err
}
}
var longTermRecords []models.GPURecord
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
@@ -176,6 +176,7 @@ func CompactRecord() error {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
//log.Printf("Record compaction completed")
return nil
@@ -363,17 +364,17 @@ func migrateOldRecords(db *gorm.DB) error {
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
return err
}
if len(gpuRecords) == 0 {
return nil
}
// 按Client + DeviceIndex + 15分钟时间窗口分组
type gpuGroupKey struct {
Client string
@@ -381,16 +382,16 @@ func migrateGPURecords(db *gorm.DB) error {
TimeSlot time.Time
DeviceName string
}
type gpuGroupData struct {
MemTotal []int64
MemUsed []int64
Utilization []float32
Temperature []int
}
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
for _, record := range gpuRecords {
key := gpuGroupKey{
Client: record.Client,
@@ -398,18 +399,18 @@ func migrateGPURecords(db *gorm.DB) error {
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
DeviceName: record.DeviceName,
}
if _, ok := groupedGPUs[key]; !ok {
groupedGPUs[key] = &gpuGroupData{}
}
data := groupedGPUs[key]
data.MemTotal = append(data.MemTotal, record.MemTotal)
data.MemUsed = append(data.MemUsed, record.MemUsed)
data.Utilization = append(data.Utilization, record.Utilization)
data.Temperature = append(data.Temperature, record.Temperature)
}
// 百分位数计算函数 (复用传统Record压缩逻辑)
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
@@ -457,7 +458,7 @@ func migrateGPURecords(db *gorm.DB) error {
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
@@ -480,10 +481,10 @@ func migrateGPURecords(db *gorm.DB) error {
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
}
if existingCount > 0 {
// 更新已存在记录
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
return err
}
@@ -494,12 +495,12 @@ func migrateGPURecords(db *gorm.DB) error {
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
return nil
})
}
-4
View File
@@ -20,10 +20,6 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
```bash
-4
View File
@@ -21,10 +21,6 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
+1 -5
View File
@@ -6,7 +6,7 @@
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
@@ -21,10 +21,6 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Claw Run - USD 1.5/月
[![](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
+11 -3
View File
@@ -1,9 +1,13 @@
module github.com/komari-monitor/komari
go 1.23.2
go 1.24.0
toolchain go1.24.11
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -12,6 +16,7 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/image v0.35.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
@@ -35,20 +40,23 @@ require (
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.27 // indirect
@@ -63,7 +71,7 @@ require (
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+18 -3
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -17,6 +19,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -37,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -47,6 +57,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -93,7 +105,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -133,6 +144,8 @@ golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -140,8 +153,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
@@ -155,6 +168,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9
View File
@@ -2,12 +2,21 @@ package main
import (
"log"
"log/slog"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
logutil "github.com/komari-monitor/komari/utils/log"
)
func main() {
if utils.VersionHash == "unknown" {
logutil.SetupGlobalLogger(slog.LevelDebug)
} else {
logutil.SetupGlobalLogger(slog.LevelInfo)
}
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
+13 -1
View File
@@ -17,11 +17,13 @@ func Parse(v any) []Item {
val = val.Elem()
}
var allowTypes = []string{"option", "richtext"}
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
typ := field.Tag.Get("type")
if typ != "option" {
if !contains(allowTypes, typ) {
typ = field.Type.Name()
}
item := Item{
@@ -39,3 +41,13 @@ func Parse(v any) []Item {
}
return items
}
// contains reports whether s is present in slice.
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
+78
View File
@@ -0,0 +1,78 @@
package log
import (
"fmt"
"log/slog"
"time"
"github.com/gin-gonic/gin"
)
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
// 处理请求
c.Next()
// 计算延迟
latency := time.Since(start)
// 获取状态码
statusCode := c.Writer.Status()
statusCodeColored := func(code int) string {
if code >= 500 {
return Red("%d", code)
} else if code >= 400 {
return Yellow("%d", code)
} else if code >= 300 {
return Cyan("%d", code)
} else {
return Green("%d", code)
}
}(statusCode)
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
if query != "" {
msg += "?" + query
}
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
if len(c.Errors) > 0 {
msg += " | " + c.Errors.String()
}
handler := slog.Default().Handler()
var level slog.Level
if statusCode >= 500 {
level = slog.LevelError
} else {
level = slog.LevelInfo
}
r := slog.NewRecord(time.Now(), level, msg, 0)
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
handler.Handle(c.Request.Context(), r)
}
}
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
handler := slog.Default().Handler()
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GIN"))
handler.Handle(c.Request.Context(), r)
c.AbortWithStatus(500)
}
}()
c.Next()
}
}
+93
View File
@@ -0,0 +1,93 @@
package log
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
komari_utils "github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"gorm.io/gorm/utils"
)
// GormLogger 实现 gorm.io/gorm/logger.Interface
type GormLogger struct {
SlowThreshold time.Duration
IgnoreRecordNotFoundError bool
LogLevel gormlogger.LogLevel
}
// NewGormLogger 创建 GORM logger
func NewGormLogger() *GormLogger {
return &GormLogger{
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
LogLevel: func(hash string) gormlogger.LogLevel {
if hash == "unknown" {
return gormlogger.Info
}
return gormlogger.Silent
}(komari_utils.VersionHash),
}
}
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newlogger := *l
newlogger.LogLevel = level
return &newlogger
}
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Info {
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Warn {
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if l.LogLevel >= gormlogger.Error {
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
}
}
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.LogLevel <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fileWithLineNum := utils.FileWithLineNum()
handler := slog.Default().Handler()
switch {
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
case l.LogLevel >= gormlogger.Info:
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
r.AddAttrs(slog.String("_group", "GORM"))
handler.Handle(ctx, r)
}
}
+180
View File
@@ -0,0 +1,180 @@
package log
import (
"context"
"fmt"
"io"
stdlog "log"
"log/slog"
"os"
"runtime"
"time"
)
func Green(format string, v ...interface{}) string {
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
}
func Yellow(format string, v ...interface{}) string {
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
}
func Red(format string, v ...interface{}) string {
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
}
func Blue(format string, v ...interface{}) string {
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
}
func Cyan(format string, v ...interface{}) string {
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
}
func Gray(format string, v ...interface{}) string {
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
}
func White(format string, v ...interface{}) string {
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
}
type LogHandler struct {
w io.Writer
level slog.Level
group string
}
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
return &LogHandler{w: w, level: level, group: ""}
}
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
var file string
var line int
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
file = f.File
line = f.Line
}
timeStr := r.Time.Format("2006/01/02 15:04:05")
// 检查是否有分组信息(从 attributes 中获取)
group := h.group
r.Attrs(func(a slog.Attr) bool {
if a.Key == "_group" {
group = a.Value.String()
return false // 停止迭代
}
return true
})
levelStr := ""
switch r.Level {
case slog.LevelDebug:
if group != "" {
levelStr = Cyan("[DEBUG/%s]", group)
} else {
levelStr = Cyan("[DEBUG]")
}
case slog.LevelInfo:
if group != "" {
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
} else {
levelStr = Green("[INFO]")
}
case slog.LevelWarn:
if group != "" {
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
} else {
levelStr = Yellow("[WARN]")
}
case slog.LevelError:
if group != "" {
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
} else {
levelStr = Red("[ERROR]")
}
}
var msg string
if file != "" {
msg = fmt.Sprintf("%s %s %s %s",
timeStr,
levelStr,
r.Message,
Gray(fmt.Sprintf("(%s:%d)", file, line)))
} else {
msg = fmt.Sprintf("%s %s %s",
timeStr,
levelStr,
r.Message)
}
r.Attrs(func(a slog.Attr) bool {
if a.Key != "_group" { // 跳过内部使用的 _group 属性
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
}
return true
})
msg += "\n"
_, err := h.w.Write([]byte(msg))
return err
}
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
}
func (h *LogHandler) WithGroup(name string) slog.Handler {
return h
}
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
func SetupGlobalLogger(level slog.Level) {
handler := NewHandler(os.Stdout, level)
logger := slog.New(handler)
// 设置 slog 默认 logger
slog.SetDefault(logger)
// 设置标准库 log 使用 slog
stdlog.SetOutput(os.Stdout)
stdlog.SetFlags(0) // 清除默认标志
stdlog.SetPrefix("")
// 替换标准库 log 的输出为 slog handler
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
}
// writerAdapter 将标准库 log 的输出适配到 slog
type writerAdapter struct {
handler slog.Handler
level slog.Level
}
func (w *writerAdapter) Write(p []byte) (n int, err error) {
msg := string(p)
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
msg = msg[:len(msg)-1]
}
var pcs [1]uintptr
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
return len(p), w.handler.Handle(context.Background(), r)
}
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
func GetWriter() io.Writer {
handler := slog.Default().Handler()
return &writerAdapter{handler: handler, level: slog.LevelInfo}
}
+3
View File
@@ -4,6 +4,9 @@ import (
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchan3"
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
+3 -9
View File
@@ -77,7 +77,7 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
if serverURL == "" {
serverURL = "https://api.day.app"
}
// 确保 URL 以正确的格式结尾
serverURL = strings.TrimRight(serverURL, "/")
requestURL := fmt.Sprintf("%s/push", serverURL)
@@ -96,8 +96,8 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
@@ -114,11 +114,5 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
return nil
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*BarkSender)(nil)
+72 -7
View File
@@ -19,6 +19,59 @@ type EmailSender struct {
Addition
}
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
type loginAuth struct {
username string
password string
host string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name":
return []byte(a.username), nil
case "Password:", "password:":
return []byte(a.password), nil
default:
// 某些服务器可能发送 base64 编码的提示
// 尝试返回用户名或密码
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
if strings.Contains(prompt, "user") {
return []byte(a.username), nil
}
return []byte(a.password), nil
}
}
return nil, nil
}
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
type plainAuthWithoutCheck struct {
identity string
username string
password string
host string
}
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, fmt.Errorf("unexpected server challenge")
}
return nil, nil
}
func (e *EmailSender) GetName() string {
return "email"
}
@@ -41,13 +94,25 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
return fmt.Errorf("email sending is not fully configured")
}
// Prepare auth
auth := smtp.PlainAuth(
"",
e.Addition.Username,
e.Addition.Password,
e.Addition.Host,
)
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
var auth smtp.Auth
if e.Addition.UseLoginAuth {
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
auth = &loginAuth{
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
} else {
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
auth = &plainAuthWithoutCheck{
identity: "",
username: e.Addition.Username,
password: e.Addition.Password,
host: e.Addition.Host,
}
}
// Parse sender address (for MAIL FROM and header)
var senderAddr string
+8 -7
View File
@@ -5,13 +5,14 @@ import (
)
type Addition struct {
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
Host string `json:"host" required:"true"`
Port int `json:"port" required:"true" default:"587"`
Username string `json:"username"`
Password string `json:"password"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
UseSSL bool `json:"use_ssl" default:"false"`
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
}
func init() {
+7
View File
@@ -1,5 +1,7 @@
package factory
import "github.com/komari-monitor/komari/database/models"
type IMessageSender interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
@@ -9,6 +11,11 @@ type IMessageSender interface {
Destroy() error
}
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
type IEventMessageSender interface {
SendEvent(event models.EventMessage) error
}
type Configuration interface{}
type MessageSenderConstructor func() IMessageSender
+301
View File
@@ -0,0 +1,301 @@
package javascript
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/dop251/goja"
)
// createFetchFunction 创建一个 fetch API 实现
func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
panic(j.vm.NewTypeError("fetch requires at least 1 argument"))
}
url := call.Argument(0).String()
// 解析选项
options := map[string]interface{}{
"method": "GET",
"headers": make(map[string]string),
"body": "",
}
if len(call.Arguments) > 1 {
optObj := call.Argument(1).ToObject(j.vm)
if optObj != nil {
if method := optObj.Get("method"); method != nil && !goja.IsUndefined(method) {
options["method"] = method.String()
}
if headers := optObj.Get("headers"); headers != nil && !goja.IsUndefined(headers) {
headersObj := headers.ToObject(j.vm)
if headersObj != nil {
headerMap := make(map[string]string)
for _, key := range headersObj.Keys() {
headerMap[key] = headersObj.Get(key).String()
}
options["headers"] = headerMap
}
}
if body := optObj.Get("body"); body != nil && !goja.IsUndefined(body) {
options["body"] = body.String()
}
}
}
// 创建 Promise
promise, resolve, reject := j.vm.NewPromise()
go func() {
defer func() {
if r := recover(); r != nil {
reject(j.vm.ToValue(fmt.Sprintf("fetch panic: %v", r)))
}
}()
// 创建 HTTP 请求
method := options["method"].(string)
var body io.Reader
if options["body"].(string) != "" {
body = strings.NewReader(options["body"].(string))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to create request: %v", err)))
return
}
// 设置请求头
headers := options["headers"].(map[string]string)
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
return
}
defer resp.Body.Close()
// 读取响应体
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Failed to read response: %v", err)))
return
}
// 创建响应对象
responseObj := j.vm.NewObject()
responseObj.Set("status", resp.StatusCode)
responseObj.Set("statusText", resp.Status)
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
// 响应头
headersObj := j.vm.NewObject()
for key, values := range resp.Header {
if len(values) > 0 {
headersObj.Set(key, values[0])
}
}
responseObj.Set("headers", headersObj)
// text() 方法
responseObj.Set("text", func(goja.FunctionCall) goja.Value {
textPromise, textResolve, _ := j.vm.NewPromise()
textResolve(j.vm.ToValue(string(bodyBytes)))
return j.vm.ToValue(textPromise)
})
// json() 方法
responseObj.Set("json", func(goja.FunctionCall) goja.Value {
jsonPromise, jsonResolve, jsonReject := j.vm.NewPromise()
var result interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
jsonReject(j.vm.ToValue(fmt.Sprintf("Failed to parse JSON: %v", err)))
} else {
jsonResolve(j.vm.ToValue(result))
}
return j.vm.ToValue(jsonPromise)
})
resolve(responseObj)
}()
return j.vm.ToValue(promise)
}
}
// createXHRConstructor 创建一个 XMLHttpRequest 构造函数
func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *goja.Object {
return func(call goja.ConstructorCall) *goja.Object {
xhr := call.This
// 内部状态
var method, url string
var headers = make(map[string]string)
var requestBody string
var async = true
// readyState
xhr.Set("readyState", 0)
xhr.Set("status", 0)
xhr.Set("statusText", "")
xhr.Set("responseText", "")
xhr.Set("response", "")
// 事件处理器
xhr.Set("onreadystatechange", goja.Null())
xhr.Set("onload", goja.Null())
xhr.Set("onerror", goja.Null())
// open 方法
xhr.Set("open", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("open requires at least 2 arguments"))
}
method = call.Argument(0).String()
url = call.Argument(1).String()
if len(call.Arguments) > 2 {
async = call.Argument(2).ToBoolean()
}
xhr.Set("readyState", 1)
j.callHandler(xhr, "onreadystatechange")
return goja.Undefined()
})
// setRequestHeader 方法
xhr.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
panic(j.vm.NewTypeError("setRequestHeader requires 2 arguments"))
}
key := call.Argument(0).String()
value := call.Argument(1).String()
headers[key] = value
return goja.Undefined()
})
// send 方法
xhr.Set("send", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
requestBody = call.Argument(0).String()
}
sendFunc := func() {
defer func() {
if r := recover(); r != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", fmt.Sprintf("Error: %v", r))
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
}
}()
// 创建请求
var body io.Reader
if requestBody != "" {
body = bytes.NewReader([]byte(requestBody))
}
req, err := http.NewRequest(method, url, body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 设置请求头
for key, value := range headers {
req.Header.Set(key, value)
}
// 发送请求
xhr.Set("readyState", 2)
j.callHandler(xhr, "onreadystatechange")
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", 0)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
defer resp.Body.Close()
// 读取响应
xhr.Set("readyState", 3)
j.callHandler(xhr, "onreadystatechange")
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", err.Error())
j.callHandler(xhr, "onerror")
j.callHandler(xhr, "onreadystatechange")
return
}
// 完成
xhr.Set("readyState", 4)
xhr.Set("status", resp.StatusCode)
xhr.Set("statusText", resp.Status)
xhr.Set("responseText", string(bodyBytes))
xhr.Set("response", string(bodyBytes))
j.callHandler(xhr, "onreadystatechange")
j.callHandler(xhr, "onload")
}
if async {
go sendFunc()
} else {
sendFunc()
}
return goja.Undefined()
})
// getAllResponseHeaders 方法
xhr.Set("getAllResponseHeaders", func(call goja.FunctionCall) goja.Value {
return j.vm.ToValue("")
})
// getResponseHeader 方法
xhr.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
return goja.Null()
})
return nil
}
}
// callHandler 调用事件处理器
func (j *JavaScriptSender) callHandler(obj *goja.Object, handlerName string) {
handler := obj.Get(handlerName)
if handler != nil && !goja.IsUndefined(handler) && !goja.IsNull(handler) {
if fn, ok := goja.AssertFunction(handler); ok {
fn(obj)
}
}
}
@@ -0,0 +1,361 @@
package javascript
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type JavaScriptSender struct {
Addition
vm *goja.Runtime
noopProgram *goja.Program
}
func (j *JavaScriptSender) GetName() string {
return "Javascript"
}
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
return &j.Addition
}
func (j *JavaScriptSender) Init() error {
if j.Addition.Script == "" {
return errors.New("JavaScript script is empty")
}
// 创建 JavaScript 运行时
j.vm = goja.New()
// 预编译一个 no-op 程序,用于驱动微任务队列
prog, errc := goja.Compile("noop.js", "void 0", false)
if errc == nil {
j.noopProgram = prog
}
// 设置 require 支持
new(require.Registry).Enable(j.vm)
// 注入全局对象和函数
j.setupGlobals()
// 加载用户脚本
_, err := j.vm.RunString(j.Addition.Script)
if err != nil {
return fmt.Errorf("failed to load JavaScript script: %v", err)
}
// 验证 sendMessage 函数是否存在
sendMessage := j.vm.Get("sendMessage")
if sendMessage == nil || goja.IsUndefined(sendMessage) {
return errors.New("sendMessage function not defined in script")
}
// 验证是否可调用
if _, ok := goja.AssertFunction(sendMessage); !ok {
return errors.New("sendMessage is not a function")
}
// sendEvent 函数是可选的,不强制要求存在
return nil
}
func (j *JavaScriptSender) Destroy() error {
if j.vm != nil {
j.vm = nil
}
return nil
}
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 获取 sendMessage 函数
sendMessageFunc, ok := goja.AssertFunction(j.vm.Get("sendMessage"))
if !ok {
return errors.New("sendMessage is not a callable function")
}
// 调用 sendMessage 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendMessageFunc(goja.Undefined(), j.vm.ToValue(message), j.vm.ToValue(title))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendMessage returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendMessage returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
if j.vm == nil {
if err := j.Init(); err != nil {
return err
}
}
// 检查是否定义了 sendEvent 函数
sendEventValue := j.vm.Get("sendEvent")
if sendEventValue == nil || goja.IsUndefined(sendEventValue) {
// 如果没有定义 sendEvent,则回退到使用 SendTextMessage
return j.fallbackToTextMessage(event)
}
sendEventFunc, ok := goja.AssertFunction(sendEventValue)
if !ok {
// 如果 sendEvent 不是函数,回退到 SendTextMessage
return j.fallbackToTextMessage(event)
}
// 将 EventMessage 转换为 JavaScript 对象
eventJSON, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %v", err)
}
var eventMap map[string]interface{}
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
return fmt.Errorf("failed to unmarshal event: %v", err)
}
// 调用 sendEvent 函数
resultChan := make(chan error, 1)
timeoutChan := time.After(30 * time.Second)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
}
}()
result, err := sendEventFunc(goja.Undefined(), j.vm.ToValue(eventMap))
if err != nil {
resultChan <- fmt.Errorf("JavaScript error: %v", err)
return
}
// 处理 Promise 返回值
if promise, ok := result.Export().(*goja.Promise); ok {
// 等待 Promise 完成
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChan:
resultChan <- errors.New("JavaScript execution timeout")
return
case <-ticker.C:
// 运行微任务以处理 Promise 回调
j.runMicrotasks()
state := promise.State()
if state == goja.PromiseStateFulfilled {
// Promise 成功完成,检查返回值
promiseResult := promise.Result()
if !promiseResult.ToBoolean() {
resultChan <- errors.New("sendEvent returned false")
} else {
resultChan <- nil
}
return
} else if state == goja.PromiseStateRejected {
// Promise 被拒绝
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
return
}
// state == goja.PromiseStatePending, 继续等待
}
}
} else {
// 处理布尔或其他返回值
if result.ToBoolean() {
resultChan <- nil
} else {
resultChan <- errors.New("sendEvent returned false")
}
}
}()
select {
case err := <-resultChan:
return err
case <-timeoutChan:
return errors.New("JavaScript execution timeout after 30 seconds")
}
}
// fallbackToTextMessage 当没有定义 sendEvent 时,回退到使用文本消息格式
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
// 构建简单的文本消息
message := fmt.Sprintf("%s%s%s\nEvent: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
event.Message,
event.Time.Format(time.RFC3339))
// 添加客户端信息
if len(event.Clients) > 0 {
clientNames := make([]string, 0, len(event.Clients))
for _, c := range event.Clients {
name := c.Name
if name == "" {
name = c.UUID
}
clientNames = append(clientNames, name)
}
message = fmt.Sprintf("%s%s%s\nEvent: %s\nClients: %s\nMessage: %s\nTime: %s",
event.Emoji, event.Emoji, event.Emoji,
event.Event,
clientNames,
event.Message,
event.Time.Format(time.RFC3339))
}
return j.SendTextMessage(message, event.Event)
}
// runMicrotasks 安全地推动 goja 的微任务队列(例如 Promise 回调)
func (j *JavaScriptSender) runMicrotasks() {
if j.vm == nil {
return
}
if j.noopProgram != nil {
_, _ = j.vm.RunProgram(j.noopProgram)
return
}
// 兜底: 直接运行一段 no-op 代码
_, _ = j.vm.RunString("void 0")
}
func (j *JavaScriptSender) setupGlobals() {
// 注入 console.log
console := j.vm.NewObject()
console.Set("log", func(call goja.FunctionCall) goja.Value {
var args []interface{}
for _, arg := range call.Arguments {
args = append(args, arg.Export())
}
fmt.Println(args...)
return goja.Undefined()
})
console.Set("error", func(call goja.FunctionCall) goja.Value {
fmt.Print("Error: ")
for i, arg := range call.Arguments {
if i > 0 {
fmt.Print(" ")
}
fmt.Print(arg.Export())
}
fmt.Println()
return goja.Undefined()
})
j.vm.Set("console", console)
// 注入 fetch API
j.vm.Set("fetch", j.createFetchFunction())
// 注入 XMLHttpRequest (xhr)
j.vm.Set("XMLHttpRequest", j.createXHRConstructor())
// 注入 setTimeout
j.vm.Set("setTimeout", func(call goja.FunctionCall) goja.Value {
callback := call.Argument(0)
delay := call.Argument(1).ToInteger()
go func() {
time.Sleep(time.Duration(delay) * time.Millisecond)
if fn, ok := goja.AssertFunction(callback); ok {
fn(goja.Undefined())
}
}()
return goja.Undefined()
})
// 注入 Promise 构造函数
j.vm.RunString(`
if (typeof Promise === 'undefined') {
// Promise polyfill 会由 goja 自动提供
}
`)
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &JavaScriptSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
+5
View File
@@ -0,0 +1,5 @@
package javascript
type Addition struct {
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
}
+16
View File
@@ -103,6 +103,22 @@ func SendEvent(event models.EventMessage) error {
if !cfg.NotificationEnabled {
return nil
}
// 检查提供者是否实现了 IEventMessageSender 接口
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
// 如果实现了,直接调用 SendEvent
for i := 0; i < 3; i++ {
err = eventSender.SendEvent(event)
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
return err
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg.NotificationTemplate
if messageTemplate == "" {
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
+21
View File
@@ -0,0 +1,21 @@
package serverchan3
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱³ 推送通道的配置项
// 所有字段通过管理页面以 JSON 形式进行设置
type Addition struct {
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
}
// 注册 Server酱³ 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChan3Sender{}
})
}
@@ -0,0 +1,105 @@
package serverchan3
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChan3Sender 为 Server酱³ 推送实现
type ServerChan3Sender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChan3Sender) GetName() string {
return "Server酱³"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChan3Sender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChan3Sender) Destroy() error { return nil }
// SendTextMessage 发送文本消息(携带标题)。
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
}
// 简化校验:若标题与正文均为空则拒绝发送
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchan3: 标题与正文均为空")
}
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
payload := map[string]string{
"title": finalTitle,
"desp": finalMessage,
}
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
parts := strings.Split(tagsStr, "|")
var cleaned []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
cleaned = append(cleaned, t)
}
}
if len(cleaned) > 0 {
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
payload["tags"] = strings.Join(cleaned, "|")
}
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 固定设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 读取响应体用于错误信息打印
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
@@ -0,0 +1,25 @@
package serverchanturbo
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// Addition 为 Server酱 Turbo 推送通道的配置项
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
type Addition struct {
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
// NoIP 是否隐藏调用 IP,填 1 则隐藏
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
}
// 注册 Server酱 Turbo 推送通道到工厂
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &ServerChanTurboSender{}
})
}
@@ -0,0 +1,95 @@
package serverchanturbo
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
type ServerChanTurboSender struct {
Addition
}
// GetName 返回推送通道名称
func (s *ServerChanTurboSender) GetName() string {
return "Server酱Turbo"
}
// GetConfiguration 返回配置结构体指针
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
return &s.Addition
}
// Init 初始化(当前无需处理)
func (s *ServerChanTurboSender) Init() error { return nil }
// Destroy 清理(当前无需处理)
func (s *ServerChanTurboSender) Destroy() error { return nil }
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
apiURL := strings.TrimSpace(s.Addition.APIURL)
if apiURL == "" {
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
}
finalTitle := strings.TrimSpace(title)
finalMessage := strings.TrimSpace(message)
if finalTitle == "" && finalMessage == "" {
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
}
payload := map[string]interface{}{
"title": finalTitle,
"desp": finalMessage,
}
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
payload["channel"] = ch
}
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
payload["noip"] = "1"
}
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
payload["openid"] = openid
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
}
// 使用 JSON 传递参数,需要设置 Content-Type
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(b))
if msg == "" {
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
}
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
}
return nil
}
// 确保实现 IMessageSender 接口
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
+28 -5
View File
@@ -140,22 +140,31 @@ func checkMetricThreshold(records []models.Record, task models.LoadNotification)
// 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 "net_in", "netin":
return bytesPerSecondToMbps(record.NetIn)
case "net_out", "netout":
return bytesPerSecondToMbps(record.NetOut)
case "ram":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.RamTotal > 0 {
return float32(record.Ram) / float32(client.MemTotal) * 100
}
return 0
case "swap":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.SwapTotal > 0 {
return float32(record.Swap) / float32(client.SwapTotal) * 100
}
@@ -165,6 +174,11 @@ func getMetricValue(record models.Record, metric string) float32 {
case "temp":
return record.Temp
case "disk":
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
if err != nil {
log.Printf("Failed to get client info for %s: %v", record.Client, err)
return 0
}
if record.DiskTotal > 0 {
return float32(record.Disk) / float32(client.DiskTotal) * 100
}
@@ -187,6 +201,15 @@ func getMetricValue(record models.Record, metric string) float32 {
}
}
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
if bytesPerSecond <= 0 {
return 0
}
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
}
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
ex_clients := []models.Client{}