mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-07 06:40:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb22a818bf | ||
|
|
b66fe47487 | ||
|
|
98122fa4d1 | ||
|
|
c3f65b1a32 | ||
|
|
7da616ce4c | ||
|
|
9d914e70b9 | ||
|
|
6f0624e9b4 | ||
|
|
3cc67e11cd | ||
|
|
742a5e953e | ||
|
|
3b82cee4b1 | ||
|
|
2b010954a3 | ||
|
|
6f23565517 | ||
|
|
7c2896c3fe | ||
|
|
721d61bc4e | ||
|
|
014b236745 | ||
|
|
80c8d7e5ee | ||
|
|
8423881cc4 | ||
|
|
a1d644977c | ||
|
|
301fb536f2 | ||
|
|
49a8d772c8 | ||
|
|
a9adae29d2 | ||
|
|
d6e23baa19 | ||
|
|
658e7603f8 | ||
|
|
fb0b9b9910 | ||
|
|
e149e8b8b7 | ||
|
|
d2cc1503de | ||
|
|
f86803feff | ||
|
|
a2e3c7218d |
No files matched your search
+10
-10
@@ -65,20 +65,20 @@ jobs:
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
git clone https://github.com/komari-monitor/komari-web komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -16,25 +16,25 @@ jobs:
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
git clone https://github.com/komari-monitor/komari-web komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -28,20 +28,20 @@ jobs:
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
git clone https://github.com/komari-monitor/komari-web komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to regenerate notes for."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Generate and apply release notes
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const tag = context.payload.release?.tag_name || core.getInput("tag");
|
||||
if (!tag) {
|
||||
core.setFailed("No release tag was provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const release = context.payload.release?.tag_name === tag
|
||||
? context.payload.release
|
||||
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag })).data;
|
||||
|
||||
const generated = await github.request(
|
||||
"POST /repos/{owner}/{repo}/releases/generate-notes",
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
tag_name: tag,
|
||||
target_commitish: release.target_commitish || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const body = generated.data.body?.trim();
|
||||
if (!body) {
|
||||
core.setFailed(`GitHub returned empty release notes for ${tag}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: release.id,
|
||||
body,
|
||||
});
|
||||
|
||||
await core.summary
|
||||
.addHeading("Release Notes")
|
||||
.addRaw(`Updated release notes for ${tag}.`)
|
||||
.write();
|
||||
@@ -31,20 +31,20 @@ jobs:
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
git clone https://github.com/komari-monitor/komari-web komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -68,20 +68,20 @@ jobs:
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
cd web
|
||||
git clone https://github.com/komari-monitor/komari-web komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
|
||||
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
+2
-1
@@ -33,4 +33,5 @@ go.work.sum
|
||||
.env
|
||||
|
||||
# build
|
||||
komari
|
||||
komari
|
||||
.codex
|
||||
@@ -93,7 +93,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
Copy the static files generated in step 1 to the `/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
|
||||
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getClientIPType(ip net.IP) int {
|
||||
// 0:ipv4 1:ipv6 -1:错误的输入
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func UploadBasicInfo(c *gin.Context) {
|
||||
var cbi = map[string]interface{}{}
|
||||
if err := c.ShouldBindJSON(&cbi); err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
uuid, err := clients.GetClientUUIDByToken(token)
|
||||
if uuid == "" || err != nil {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
// 如果没有传入 IP 信息,尝试从请求中获取
|
||||
if (func() bool {
|
||||
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
|
||||
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})() {
|
||||
ipStr := c.ClientIP()
|
||||
ip := net.ParseIP(ipStr)
|
||||
ipType := getClientIPType(ip)
|
||||
|
||||
switch ipType {
|
||||
case 0:
|
||||
cbi["ipv4"] = ipStr
|
||||
case 1:
|
||||
cbi["ipv6"] = ipStr
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
if ip4_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.ISOCode)
|
||||
}
|
||||
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
|
||||
ip6 := net.ParseIP(ipv6)
|
||||
ip6_record, _ := geoip.GetGeoInfo(ip6)
|
||||
if ip6_record != nil {
|
||||
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.ISOCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := clients.SaveClientInfo(cbi); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": err})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
}
|
||||
+33
-6
@@ -1,14 +1,41 @@
|
||||
package flags
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DatabaseTypeSQLite = "sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
// 数据库配置
|
||||
DatabaseType string // 数据库类型:sqlite, mysql
|
||||
DatabaseType string // 数据库类型:sqlite
|
||||
DatabaseFile string // SQLite数据库文件路径
|
||||
DatabaseHost string // MySQL/其他数据库主机地址
|
||||
DatabasePort string // MySQL/其他数据库端口
|
||||
DatabaseUser string // MySQL/其他数据库用户名
|
||||
DatabasePass string // MySQL/其他数据库密码
|
||||
DatabaseName string // MySQL/其他数据库名称
|
||||
DatabaseHost string // 保留的兼容参数,当前未使用
|
||||
DatabasePort string // 保留的兼容参数,当前未使用
|
||||
DatabaseUser string // 保留的兼容参数,当前未使用
|
||||
DatabasePass string // 保留的兼容参数,当前未使用
|
||||
DatabaseName string // 保留的兼容参数,当前未使用
|
||||
|
||||
Listen string
|
||||
)
|
||||
|
||||
func NormalizeDatabaseType(databaseType string) string {
|
||||
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
|
||||
if databaseType == "" {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
return databaseType
|
||||
}
|
||||
|
||||
func ApplyDatabaseTypeNormalization() string {
|
||||
DatabaseType = NormalizeDatabaseType(DatabaseType)
|
||||
return DatabaseType
|
||||
}
|
||||
|
||||
func IsSQLite() bool {
|
||||
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
|
||||
}
|
||||
|
||||
func SupportedDatabaseTypes() string {
|
||||
return DatabaseTypeSQLite
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package flags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDatabaseType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": DatabaseTypeSQLite,
|
||||
"sqlite": DatabaseTypeSQLite,
|
||||
" SQLite ": DatabaseTypeSQLite,
|
||||
"SQLITE": DatabaseTypeSQLite,
|
||||
"postgres": "postgres",
|
||||
" postgres": "postgres",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeDatabaseType(input); got != want {
|
||||
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var PermitPasswordLoginCmd = &cobra.Command{
|
||||
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
|
||||
Short: "Force permit password login",
|
||||
Long: `Force permit password login`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Model(&models.Config{}).Where("id = ?", 1).
|
||||
Update("disable_password_login", false).Error
|
||||
})
|
||||
if err != nil {
|
||||
dbcore.GetDBInstance()
|
||||
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
|
||||
cmd.Println("Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+9
-6
@@ -48,11 +48,14 @@ func Execute() {
|
||||
|
||||
func init() {
|
||||
// 设置命令行参数,提供环境变量作为默认值
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
|
||||
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
|
||||
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
|
||||
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
|
||||
_ = RootCmd.PersistentFlags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
+51
-252
@@ -11,20 +11,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/api/admin"
|
||||
"github.com/komari-monitor/komari/api/admin/clipboard"
|
||||
log_api "github.com/komari-monitor/komari/api/admin/log"
|
||||
"github.com/komari-monitor/komari/api/admin/notification"
|
||||
"github.com/komari-monitor/komari/api/admin/test"
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/jsonRpc"
|
||||
public_api "github.com/komari-monitor/komari/api/public"
|
||||
"github.com/komari-monitor/komari/api/terminal"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
@@ -33,21 +23,21 @@ import (
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/web/nezha"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
report_cache "github.com/komari-monitor/komari/web/report"
|
||||
"github.com/komari-monitor/komari/web/router"
|
||||
"github.com/komari-monitor/komari/web/security"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
DynamicCorsEnabled bool = false
|
||||
)
|
||||
|
||||
var ServerCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Short: "Start the server",
|
||||
@@ -73,7 +63,7 @@ func RunServer() {
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.GetManyAs[config.Legacy]()
|
||||
conf, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -85,7 +75,7 @@ func RunServer() {
|
||||
|
||||
if conf.NezhaCompatEnabled {
|
||||
go func() {
|
||||
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
|
||||
if err := nezha.StartNezhaCompat(conf.NezhaCompatListen); err != nil {
|
||||
log.Printf("Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
|
||||
}
|
||||
@@ -112,12 +102,12 @@ func RunServer() {
|
||||
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
|
||||
if t {
|
||||
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
|
||||
if err := StartNezhaCompat(l); err != nil {
|
||||
if err := nezha.StartNezhaCompat(l); err != nil {
|
||||
log.Printf("start Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
|
||||
}
|
||||
} else {
|
||||
if err := StopNezhaCompat(); err != nil {
|
||||
if err := nezha.StopNezhaCompat(); err != nil {
|
||||
log.Printf("stop Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
|
||||
}
|
||||
@@ -134,13 +124,7 @@ func RunServer() {
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
// 动态 CORS 中间件
|
||||
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
|
||||
DynamicCorsEnabled = t
|
||||
}
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
@@ -150,21 +134,7 @@ func RunServer() {
|
||||
}
|
||||
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "43200") // 12 hours
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
|
||||
|
||||
r.Use(api.IdentityMiddleware())
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
@@ -176,175 +146,7 @@ func RunServer() {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Any("/ping", func(c *gin.Context) {
|
||||
c.String(200, "pong")
|
||||
})
|
||||
// #region 公开路由
|
||||
r.POST("/api/login", public_api.Login)
|
||||
r.GET("/api/me", public_api.GetMe)
|
||||
r.GET("/api/clients", api.GetClients)
|
||||
r.GET("/api/nodes", public_api.GetNodesInformation)
|
||||
r.GET("/api/public", public_api.GetPublicSettings)
|
||||
r.GET("/api/oauth", public_api.OAuth)
|
||||
r.GET("/api/oauth_callback", public_api.OAuthCallback)
|
||||
r.GET("/api/logout", public_api.Logout)
|
||||
r.GET("/api/version", public_api.GetVersion)
|
||||
r.GET("/api/recent/:uuid", public_api.GetClientRecentRecords)
|
||||
|
||||
r.GET("/api/records/load", public_api.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", public_api.GetPingRecords)
|
||||
r.GET("/api/task/ping", public_api.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.RequireRole(api.RoleAdmin, api.RoleClient))
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
|
||||
tokenAuthrized.POST("/report", client.UploadReport)
|
||||
tokenAuthrized.GET("/terminal", terminal.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.RequireRole(api.RoleAdmin))
|
||||
{
|
||||
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
|
||||
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
|
||||
// test
|
||||
testGroup := adminAuthrized.Group("/test")
|
||||
{
|
||||
testGroup.GET("/geoip", test.TestGeoIp)
|
||||
testGroup.POST("/sendMessage", test.TestSendMessage)
|
||||
}
|
||||
// update
|
||||
updateGroup := adminAuthrized.Group("/update")
|
||||
{
|
||||
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
|
||||
updateGroup.POST("/user", update.UpdateUser)
|
||||
updateGroup.PUT("/favicon", update.UploadFavicon)
|
||||
updateGroup.POST("/favicon", update.DeleteFavicon)
|
||||
}
|
||||
// tasks
|
||||
taskGroup := adminAuthrized.Group("/task")
|
||||
{
|
||||
taskGroup.GET("/all", admin.GetTasks)
|
||||
taskGroup.POST("/exec", admin.Exec)
|
||||
taskGroup.GET("/:task_id", admin.GetTaskById)
|
||||
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
|
||||
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
|
||||
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
|
||||
}
|
||||
// settings
|
||||
settingsGroup := adminAuthrized.Group("/settings")
|
||||
{
|
||||
settingsGroup.GET("/", admin.GetSettings)
|
||||
settingsGroup.POST("/", admin.EditSettings)
|
||||
settingsGroup.POST("/oidc", admin.SetOidcProvider)
|
||||
settingsGroup.GET("/oidc", admin.GetOidcProvider)
|
||||
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
|
||||
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
|
||||
settingsGroup.GET("/cloudflared", admin.GetCloudflaredStatus)
|
||||
settingsGroup.POST("/cloudflared/start", admin.StartCloudflared)
|
||||
settingsGroup.POST("/cloudflared/stop", admin.StopCloudflared)
|
||||
settingsGroup.POST("/cloudflared/remove-token", admin.RemoveCloudflaredToken)
|
||||
}
|
||||
// themes
|
||||
themeGroup := adminAuthrized.Group("/theme")
|
||||
{
|
||||
themeGroup.PUT("/upload", admin.UploadTheme)
|
||||
themeGroup.GET("/list", admin.ListThemes)
|
||||
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
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
{
|
||||
clientGroup.POST("/add", admin.AddClient)
|
||||
clientGroup.GET("/list", admin.ListClients)
|
||||
clientGroup.GET("/:uuid", admin.GetClient)
|
||||
clientGroup.POST("/:uuid/edit", admin.EditClient)
|
||||
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
|
||||
clientGroup.GET("/:uuid/token", admin.GetClientToken)
|
||||
clientGroup.POST("/order", admin.OrderWeight)
|
||||
// client terminal
|
||||
clientGroup.GET("/:uuid/terminal", terminal.RequestTerminal)
|
||||
}
|
||||
|
||||
// records
|
||||
recordGroup := adminAuthrized.Group("/record")
|
||||
{
|
||||
recordGroup.POST("/clear", admin.ClearRecord)
|
||||
recordGroup.POST("/clear/all", admin.ClearAllRecords)
|
||||
}
|
||||
// oauth2
|
||||
oauth2Group := adminAuthrized.Group("/oauth2")
|
||||
{
|
||||
oauth2Group.GET("/bind", admin.BindingExternalAccount)
|
||||
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
|
||||
}
|
||||
sessionGroup := adminAuthrized.Group("/session")
|
||||
{
|
||||
sessionGroup.GET("/get", admin.GetSessions)
|
||||
sessionGroup.POST("/remove", admin.DeleteSession)
|
||||
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
|
||||
}
|
||||
two_factorGroup := adminAuthrized.Group("/2fa")
|
||||
{
|
||||
two_factorGroup.GET("/generate", admin.Generate2FA)
|
||||
two_factorGroup.POST("/enable", admin.Enable2FA)
|
||||
two_factorGroup.POST("/disable", admin.Disable2FA)
|
||||
}
|
||||
adminAuthrized.GET("/logs", log_api.GetLogs)
|
||||
|
||||
// clipboard
|
||||
clipboardGroup := adminAuthrized.Group("/clipboard")
|
||||
{
|
||||
clipboardGroup.GET("/:id", clipboard.GetClipboard)
|
||||
clipboardGroup.GET("", clipboard.ListClipboard)
|
||||
clipboardGroup.POST("", clipboard.CreateClipboard)
|
||||
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
|
||||
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
|
||||
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
|
||||
}
|
||||
|
||||
notificationGroup := adminAuthrized.Group("/notification")
|
||||
{
|
||||
// offline notifications
|
||||
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
|
||||
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
|
||||
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
|
||||
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
|
||||
loadAlertGroup := notificationGroup.Group("/load")
|
||||
{
|
||||
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
|
||||
loadAlertGroup.POST("/add", notification.AddLoadNotification)
|
||||
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
|
||||
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
|
||||
}
|
||||
}
|
||||
|
||||
pingTaskGroup := adminAuthrized.Group("/ping")
|
||||
{
|
||||
pingTaskGroup.GET("/", admin.GetAllPingTasks)
|
||||
pingTaskGroup.POST("/add", admin.AddPingTask)
|
||||
pingTaskGroup.POST("/delete", admin.DeletePingTask)
|
||||
pingTaskGroup.POST("/edit", admin.EditPingTask)
|
||||
pingTaskGroup.POST("/order", admin.OrderPingTask)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
router.Register(r)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
@@ -370,18 +172,6 @@ func RunServer() {
|
||||
}
|
||||
|
||||
func InitDatabase() {
|
||||
// // 打印数据库类型和连接信息
|
||||
// if flags.DatabaseType == "mysql" {
|
||||
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
|
||||
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
|
||||
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
|
||||
// } else {
|
||||
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
|
||||
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
|
||||
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
|
||||
// }
|
||||
var count int64 = 0
|
||||
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
|
||||
user, passwd, err := accounts.CreateDefaultAdminAccount()
|
||||
@@ -394,41 +184,50 @@ func InitDatabase() {
|
||||
|
||||
// #region 定时任务
|
||||
func DoScheduledWork() {
|
||||
if err := tasks.MigrateAllClientsExpansion(); err != nil {
|
||||
log.Println("Failed to migrate ping task all_clients expansion:", err)
|
||||
if err := tasks.ReloadPingSchedule(); err != nil {
|
||||
log.Println("Failed to reload ping schedule:", err)
|
||||
}
|
||||
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
|
||||
log.Println("Failed to reload load notification schedule:", err)
|
||||
}
|
||||
tasks.ReloadPingSchedule()
|
||||
d_notification.ReloadLoadNotificationSchedule()
|
||||
ticker := time.NewTicker(time.Minute * 30)
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
go notifier.CheckExpireScheduledWork()
|
||||
for {
|
||||
cfg, _ := config.GetManyAs[config.Legacy]()
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
auditlog.RemoveOldLogs()
|
||||
accounts.RemoveExpiredSessions()
|
||||
case <-minute.C:
|
||||
api.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
go notifier.CheckTraffic()
|
||||
}
|
||||
}
|
||||
|
||||
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
|
||||
log.Println("Failed to add cleanup scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
|
||||
log.Println("Failed to add minute scheduled task:", err)
|
||||
}
|
||||
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
|
||||
log.Println("Failed to add expire notification scheduled task:", err)
|
||||
}
|
||||
notifier.InitTrafficReportSchedule()
|
||||
}
|
||||
|
||||
func cleanupScheduledData() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
records.CompactRecord()
|
||||
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
|
||||
auditlog.RemoveOldLogs()
|
||||
accounts.RemoveExpiredSessions()
|
||||
}
|
||||
|
||||
func minuteScheduledWork() {
|
||||
cfg, _ := config.GetManyAs[config.Settings]()
|
||||
report_cache.SaveClientReportToDB()
|
||||
if !cfg.RecordEnabled {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
notifier.CheckTraffic()
|
||||
}
|
||||
|
||||
func OnShutdown() {
|
||||
auditlog.Log("", "", "server is shutting down", "info")
|
||||
corn.StopAll()
|
||||
cloudflared.Shutdown()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
CPU bool `json:"cpu" gorm:"default:true"`
|
||||
GPU bool `json:"gpu" gorm:"default:true"`
|
||||
RAM bool `json:"ram" gorm:"default:true"`
|
||||
SWAP bool `json:"swap" gorm:"default:true"`
|
||||
LOAD bool `json:"load" gorm:"default:true"`
|
||||
UPTIME bool `json:"uptime" gorm:"default:true"`
|
||||
TEMP bool `json:"temp" gorm:"default:true"`
|
||||
OS bool `json:"os" gorm:"default:true"`
|
||||
DISK bool `json:"disk" gorm:"default:true"`
|
||||
NET bool `json:"net" gorm:"default:true"`
|
||||
PROCESS bool `json:"process" gorm:"default:true"`
|
||||
Connections bool `json:"connections" gorm:"default:true"`
|
||||
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClientInfo stores static client information
|
||||
// Deprecated: Use models.Client instead.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type IPAddress struct {
|
||||
Ipv4 string `json:"ipv4"`
|
||||
Ipv6 string `json:"ipv6"`
|
||||
}
|
||||
type Report struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
CPU CPUReport `json:"cpu"`
|
||||
Ram RamReport `json:"ram"`
|
||||
Swap RamReport `json:"swap"`
|
||||
Load LoadReport `json:"load"`
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
Method string `json:"method,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// GPUDetailReport 详细GPU信息报告
|
||||
type GPUDetailReport struct {
|
||||
Count int `json:"count"` // GPU数量
|
||||
AverageUsage float64 `json:"average_usage"` // 平均使用率
|
||||
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
|
||||
}
|
||||
|
||||
// GPUDeviceInfo 单个GPU设备信息
|
||||
type GPUDeviceInfo struct {
|
||||
Name string `json:"name"` // GPU型号名称
|
||||
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
|
||||
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
|
||||
Utilization float64 `json:"utilization"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// 保持向后兼容的旧GPUReport结构
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type RamReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type LoadReport struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type DiskReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type NetworkReport struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
TotalUp int64 `json:"totalUp"`
|
||||
TotalDown int64 `json:"totalDown"`
|
||||
}
|
||||
|
||||
type ConnectionsReport struct {
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
|
||||
+59
-122
@@ -1,32 +1,21 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
|
||||
func DeleteClientConfig(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
@@ -36,79 +25,6 @@ func DeleteClient(clientUuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
|
||||
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
if cbi.Name != "" {
|
||||
updates["name"] = cbi.Name
|
||||
}
|
||||
if cbi.CpuName != "" {
|
||||
updates["cpu_name"] = cbi.CpuName
|
||||
}
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
updates["os"] = cbi.OS
|
||||
}
|
||||
if cbi.GpuName != "" {
|
||||
updates["gpu_name"] = cbi.GpuName
|
||||
}
|
||||
if cbi.IPv4 != "" {
|
||||
updates["ipv4"] = cbi.IPv4
|
||||
}
|
||||
if cbi.IPv6 != "" {
|
||||
updates["ipv6"] = cbi.IPv6
|
||||
}
|
||||
if cbi.Region != "" {
|
||||
updates["region"] = cbi.Region
|
||||
}
|
||||
if cbi.Remark != "" {
|
||||
updates["remark"] = cbi.Remark
|
||||
}
|
||||
updates["mem_total"] = cbi.MemTotal
|
||||
updates["swap_total"] = cbi.SwapTotal
|
||||
updates["disk_total"] = cbi.DiskTotal
|
||||
updates["version"] = cbi.Version
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
// 转换为更新Client表
|
||||
client := models.Client{
|
||||
UUID: cbi.UUID,
|
||||
}
|
||||
|
||||
err := db.Model(&client).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.Assignments(updates),
|
||||
}).Create(map[string]interface{}{
|
||||
"uuid": cbi.UUID,
|
||||
"name": cbi.Name,
|
||||
"cpu_name": cbi.CpuName,
|
||||
"arch": cbi.Arch,
|
||||
"cpu_cores": cbi.CpuCores,
|
||||
"os": cbi.OS,
|
||||
"gpu_name": cbi.GpuName,
|
||||
"ipv4": cbi.IPv4,
|
||||
"ipv6": cbi.IPv6,
|
||||
"region": cbi.Region,
|
||||
"remark": cbi.Remark,
|
||||
"mem_total": cbi.MemTotal,
|
||||
"swap_total": cbi.SwapTotal,
|
||||
"disk_total": cbi.DiskTotal,
|
||||
"version": cbi.Version,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
clientUUID, ok := update["uuid"].(string)
|
||||
@@ -123,27 +39,73 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
checkInt64 := func(name string, val float64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return float64(typed), true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int8:
|
||||
return float64(typed), true
|
||||
case int16:
|
||||
return float64(typed), true
|
||||
case int32:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case uint:
|
||||
return float64(typed), true
|
||||
case uint8:
|
||||
return float64(typed), true
|
||||
case uint16:
|
||||
return float64(typed), true
|
||||
case uint32:
|
||||
return float64(typed), true
|
||||
case uint64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
|
||||
}
|
||||
|
||||
checkOptionalInt := func(name, key string, maxValue float64) error {
|
||||
value, exists := update[key]
|
||||
if !exists || value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
numericValue, ok := toFloat64(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s must be a valid number", name)
|
||||
}
|
||||
if numericValue < 0 || numericValue > maxValue {
|
||||
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
|
||||
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
|
||||
}
|
||||
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
|
||||
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
|
||||
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
|
||||
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -160,16 +122,6 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 更新客户端设置
|
||||
func UpdateClientConfig(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Save(&config).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditClientName(clientUUID, clientName string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
|
||||
@@ -179,21 +131,6 @@ func EditClientName(clientUUID, clientName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
// UpdateClientByUUID 更新指定 UUID 的客户端配置
|
||||
|
||||
func UpdateClientByUUID(config common.ClientConfig) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
func EditClientToken(clientUUID, token string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
|
||||
|
||||
@@ -2,17 +2,58 @@ package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var reportSaveLocks sync.Map
|
||||
|
||||
func getReportSaveLock(clientUUID string) *sync.Mutex {
|
||||
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func computeTrafficDelta(current, previous int64) int64 {
|
||||
if previous < 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= previous {
|
||||
return current - previous
|
||||
}
|
||||
// Counter reset or reboot: treat the new counter value as traffic since reset.
|
||||
return current
|
||||
}
|
||||
|
||||
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
|
||||
var record models.Record
|
||||
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Report 表示客户端报告数据
|
||||
// SaveReport 保存报告数据
|
||||
func SaveReport(uuid string, data map[string]interface{}) (err error) {
|
||||
@@ -40,20 +81,20 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
|
||||
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
return v1.Report{}, err
|
||||
}
|
||||
err = json.Unmarshal(jsonData, &report)
|
||||
if err != nil {
|
||||
return common.Report{}, err
|
||||
return v1.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report common.Report) error {
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
@@ -137,11 +178,14 @@ func ReportVerify(report common.Report) error {
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := ReportVerify(report); err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
return fmt.Errorf("failed to save Record: %w", err)
|
||||
}
|
||||
|
||||
// 保存GPU详细记录到独立表
|
||||
@@ -159,7 +203,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
Temperature: gpu.Temperature,
|
||||
}
|
||||
if err := db.Create(&gpuRecord).Error; err != nil {
|
||||
return fmt.Errorf("failed to save GPU record: %v", err)
|
||||
return fmt.Errorf("failed to save GPU record: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +231,8 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
NetOut: report.Network.Up,
|
||||
NetTotalUp: report.Network.TotalUp,
|
||||
NetTotalDown: report.Network.TotalDown,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
Process: report.Process,
|
||||
Connections: report.Connections.TCP,
|
||||
ConnectionsUdp: report.Connections.UDP,
|
||||
@@ -195,9 +241,18 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
|
||||
// 使用事务确保 Record 和 ClientsInfo 一致性
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
previous, err := getLatestTrafficRecord(tx, clientUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load previous Record: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
Record.TrafficUp = computeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
|
||||
Record.TrafficDown = computeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
|
||||
}
|
||||
|
||||
// 保存 Record
|
||||
if err := tx.Create(&Record).Error; err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
return fmt.Errorf("failed to save Record: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
|
||||
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
|
||||
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.want, computeTrafficDelta(test.current, test.previous))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportSaveLockSerializesSameClient(t *testing.T) {
|
||||
clientUUID := "client-lock-same"
|
||||
lock := getReportSaveLock(clientUUID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
secondLock := getReportSaveLock(clientUUID)
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("same-client report save lock was acquired while already held")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
lock.Unlock()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("same-client report save lock was not released")
|
||||
}
|
||||
lock.Lock()
|
||||
}
|
||||
|
||||
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
|
||||
firstLock := getReportSaveLock("client-lock-a")
|
||||
firstLock.Lock()
|
||||
defer firstLock.Unlock()
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
waitGroup.Add(1)
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
secondLock := getReportSaveLock("client-lock-b")
|
||||
secondLock.Lock()
|
||||
defer secondLock.Unlock()
|
||||
close(acquired)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("different-client report save lock should not block")
|
||||
}
|
||||
waitGroup.Wait()
|
||||
}
|
||||
+10
-209
@@ -2,7 +2,6 @@ package dbcore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -13,9 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/migrations"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -166,193 +165,6 @@ func unzipToDir(zipPath, dstDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
|
||||
func mergeClientInfo(db *gorm.DB) {
|
||||
var clientInfos []common.ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
log.Printf("Failed to read ClientInfo table: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, info := range clientInfos {
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新Client记录
|
||||
client.Name = info.Name
|
||||
client.CpuName = info.CpuName
|
||||
client.Virtualization = info.Virtualization
|
||||
client.Arch = info.Arch
|
||||
client.CpuCores = info.CpuCores
|
||||
client.OS = info.OS
|
||||
client.GpuName = info.GpuName
|
||||
client.IPv4 = info.IPv4
|
||||
client.IPv6 = info.IPv6
|
||||
client.Region = info.Region
|
||||
client.Remark = info.Remark
|
||||
client.PublicRemark = info.PublicRemark
|
||||
client.MemTotal = info.MemTotal
|
||||
client.SwapTotal = info.SwapTotal
|
||||
client.DiskTotal = info.DiskTotal
|
||||
client.Version = info.Version
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = models.FromTime(info.ExpiredAt)
|
||||
// Save updated Client record
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
log.Printf("Failed to update Client record: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Backup and rename old table after migration
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
log.Printf("Failed to backup ClientInfo table: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
|
||||
}
|
||||
|
||||
func MergeDatabase(db *gorm.DB) {
|
||||
if db.Migrator().HasTable("client_infos") {
|
||||
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
|
||||
mergeClientInfo(db)
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
|
||||
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
|
||||
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Merge OidcProvider table....")
|
||||
var config struct {
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for OIDC provider migration:", err)
|
||||
}
|
||||
db.AutoMigrate(&models.OidcProvider{})
|
||||
j, err := json.Marshal(&map[string]string{
|
||||
"client_id": config.OAuthClientID,
|
||||
"client_secret": config.OAuthClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal OIDC provider config:", err)
|
||||
return
|
||||
}
|
||||
db.Save(&models.OidcProvider{
|
||||
Name: "github",
|
||||
Addition: string(j),
|
||||
})
|
||||
db.AutoMigrate(&models.Config{})
|
||||
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Migrate MessageSender configuration....")
|
||||
var config struct {
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
|
||||
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
|
||||
EmailPort int `json:"email_port" gorm:"type:int"`
|
||||
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
|
||||
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
|
||||
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
|
||||
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
|
||||
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for MessageSender migration:", err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&models.MessageSenderProvider{})
|
||||
|
||||
// 迁移Telegram配置
|
||||
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
|
||||
telegramConfig := map[string]interface{}{
|
||||
"bot_token": config.TelegramBotToken,
|
||||
"chat_id": config.TelegramChatID,
|
||||
"endpoint": config.TelegramEndpoint,
|
||||
}
|
||||
if telegramConfig["endpoint"] == "" {
|
||||
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
|
||||
}
|
||||
telegramConfigJSON, err := json.Marshal(telegramConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Telegram config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "telegram",
|
||||
Addition: string(telegramConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移Email配置
|
||||
if config.NotificationMethod == "email" && config.EmailHost != "" {
|
||||
emailConfig := map[string]interface{}{
|
||||
"host": config.EmailHost,
|
||||
"port": config.EmailPort,
|
||||
"username": config.EmailUsername,
|
||||
"password": config.EmailPassword,
|
||||
"sender": config.EmailSender,
|
||||
"receiver": config.EmailReceiver,
|
||||
"use_ssl": config.EmailUseSSL,
|
||||
}
|
||||
emailConfigJSON, err := json.Marshal(emailConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Email config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "email",
|
||||
Addition: string(emailConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧的配置字段
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_host")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_port")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_username")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_password")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_sender")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
instance *gorm.DB
|
||||
once sync.Once
|
||||
@@ -412,8 +224,8 @@ func GetDBInstance() *gorm.DB {
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
switch flags.DatabaseType {
|
||||
case "sqlite", "":
|
||||
switch flags.ApplyDatabaseTypeNormalization() {
|
||||
case flags.DatabaseTypeSQLite:
|
||||
// SQLite 连接
|
||||
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
|
||||
if err != nil {
|
||||
@@ -428,35 +240,24 @@ func GetDBInstance() *gorm.DB {
|
||||
instance.Exec("PRAGMA cache_size = -65536;")
|
||||
instance.Exec("PRAGMA temp_store = MEMORY;")
|
||||
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",
|
||||
// flags.DatabaseUser,
|
||||
// flags.DatabasePass,
|
||||
// flags.DatabaseHost,
|
||||
// flags.DatabasePort,
|
||||
// flags.DatabaseName)
|
||||
// instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
// }
|
||||
// log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
|
||||
}
|
||||
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
|
||||
log.Fatalf("Failed to run startup migrations: %v", err)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.GPURecord{},
|
||||
// &models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingRecord{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
@@ -493,7 +294,7 @@ func GetDBInstance() *gorm.DB {
|
||||
}
|
||||
|
||||
// Manually create composite indexes
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
if flags.IsSQLite() {
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
|
||||
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package models
|
||||
|
||||
type Config 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"`
|
||||
// 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
|
||||
// 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
|
||||
// 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"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// 通知
|
||||
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%
|
||||
// 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天
|
||||
CreatedAt LocalTime
|
||||
UpdatedAt LocalTime
|
||||
}
|
||||
@@ -8,4 +8,7 @@ const (
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
DReport = "DReport" // 日报
|
||||
WReport = "WReport" // 周报
|
||||
MReport = "MReport" // 月报
|
||||
)
|
||||
+26
-11
@@ -15,6 +15,7 @@ type Client struct {
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
@@ -87,6 +88,8 @@ type Record struct {
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
|
||||
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
@@ -95,14 +98,14 @@ type Record struct {
|
||||
|
||||
// GPURecord logs individual GPU metrics over time
|
||||
type GPURecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time LocalTime `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
|
||||
Time LocalTime `json:"time" gorm:"index"` // 记录时间
|
||||
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
|
||||
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
|
||||
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
|
||||
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
|
||||
Temperature int `json:"temperature"` // GPU温度(°C)
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
@@ -110,9 +113,21 @@ type GPURecord struct {
|
||||
type StringArray []string
|
||||
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to scan StringArray: value is not []byte")
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
|
||||
}
|
||||
if len(bytes) == 0 {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, sa)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
|
||||
tests := map[string]interface{}{
|
||||
"bytes": []byte(`["a","b"]`),
|
||||
"text": `["a","b"]`,
|
||||
"empty": "",
|
||||
"nil": nil,
|
||||
}
|
||||
|
||||
for name, input := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var got StringArray
|
||||
if err := got.Scan(input); err != nil {
|
||||
t.Fatalf("scan StringArray: %v", err)
|
||||
}
|
||||
if input == "" || input == nil {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty StringArray, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("unexpected StringArray: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,13 @@ type LoadNotification struct {
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// TrafficReportNotification 定义了流量定时报告的数据库模型
|
||||
type TrafficReportNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
|
||||
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
|
||||
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
|
||||
}
|
||||
+112
-1
@@ -1,5 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeConfigurationManaged = "managed"
|
||||
ThemeConfigurationRaw = "raw"
|
||||
ThemeConfigurationRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
@@ -13,7 +26,7 @@ type Theme struct {
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
@@ -33,3 +46,101 @@ type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
|
||||
func (t Theme) ConfigurationType() string {
|
||||
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
|
||||
if typ == "" {
|
||||
return ThemeConfigurationManaged
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func (t Theme) RawHTML() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRaw {
|
||||
return "", false
|
||||
}
|
||||
return configurationDataString(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) RedirectTarget() (string, bool) {
|
||||
if t.ConfigurationType() != ThemeConfigurationRedirect {
|
||||
return "", false
|
||||
}
|
||||
return NormalizeThemeRedirectTarget(t.Configuration.Data)
|
||||
}
|
||||
|
||||
func (t Theme) ValidateConfiguration() error {
|
||||
switch t.ConfigurationType() {
|
||||
case ThemeConfigurationManaged:
|
||||
return nil
|
||||
case ThemeConfigurationRaw:
|
||||
html, ok := t.RawHTML()
|
||||
if !ok || strings.TrimSpace(html) == "" {
|
||||
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
|
||||
}
|
||||
return nil
|
||||
case ThemeConfigurationRedirect:
|
||||
if _, ok := t.RedirectTarget(); !ok {
|
||||
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeThemeRedirectTarget(data any) (string, bool) {
|
||||
target, ok := configurationDataString(data)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cleanInputPath := parsed.Path
|
||||
if strings.HasPrefix(cleanInputPath, "/") {
|
||||
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
|
||||
} else {
|
||||
for strings.HasPrefix(cleanInputPath, "../") {
|
||||
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
|
||||
}
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(cleanInputPath, "/") {
|
||||
if segment == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
cleanPath := cleanInputPath
|
||||
if cleanPath == "" {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = path.Clean(cleanPath)
|
||||
if cleanPath == "." {
|
||||
cleanPath = "/"
|
||||
} else {
|
||||
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
|
||||
}
|
||||
}
|
||||
|
||||
normalized := url.URL{
|
||||
Path: cleanPath,
|
||||
RawQuery: parsed.RawQuery,
|
||||
Fragment: parsed.Fragment,
|
||||
}
|
||||
return normalized.String(), true
|
||||
}
|
||||
|
||||
func configurationDataString(data any) (string, bool) {
|
||||
value, ok := data.(string)
|
||||
return value, ok
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThemeValidateConfiguration(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
theme Theme
|
||||
wantErr bool
|
||||
}{
|
||||
"managed empty data": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: ThemeConfigurationManaged},
|
||||
},
|
||||
},
|
||||
"missing type defaults to managed": {
|
||||
theme: Theme{},
|
||||
},
|
||||
"raw html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: "<!doctype html><title>raw</title>",
|
||||
},
|
||||
},
|
||||
},
|
||||
"raw rejects empty html": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRaw,
|
||||
Data: " ",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect relative path": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/dashboard?tab=nodes",
|
||||
},
|
||||
},
|
||||
},
|
||||
"redirect rejects absolute url": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "https://example.com",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"redirect rejects parent traversal": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{
|
||||
Type: ThemeConfigurationRedirect,
|
||||
Data: "/../admin",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
"unknown type": {
|
||||
theme: Theme{
|
||||
Configuration: Configuration{Type: "hosted"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := tt.theme.ValidateConfiguration()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeThemeRedirectTarget(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
input string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
"leading slash": {
|
||||
input: "/nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"missing leading slash": {
|
||||
input: "nodes",
|
||||
want: "/nodes",
|
||||
ok: true,
|
||||
},
|
||||
"parent prefix points to root path": {
|
||||
input: "../settings",
|
||||
want: "/settings",
|
||||
ok: true,
|
||||
},
|
||||
"multiple parent prefixes point to root path": {
|
||||
input: "../../settings/site",
|
||||
want: "/settings/site",
|
||||
ok: true,
|
||||
},
|
||||
"query and fragment": {
|
||||
input: "nodes?id=1#cpu",
|
||||
want: "/nodes?id=1#cpu",
|
||||
ok: true,
|
||||
},
|
||||
"absolute parent traversal": {
|
||||
input: "/../admin",
|
||||
},
|
||||
"nested parent traversal": {
|
||||
input: "nodes/../admin",
|
||||
},
|
||||
"protocol relative": {
|
||||
input: "//example.com",
|
||||
},
|
||||
"backslash": {
|
||||
input: `\admin`,
|
||||
},
|
||||
"absolute url": {
|
||||
input: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, ok := NormalizeThemeRedirectTarget(tt.input)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("target = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
|
||||
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
|
||||
if daily {
|
||||
query = query.Where("daily = ?", true)
|
||||
} else if weekly {
|
||||
query = query.Where("weekly = ?", true)
|
||||
} else if monthly {
|
||||
query = query.Where("monthly = ?", true)
|
||||
}
|
||||
err := query.Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
|
||||
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Enable: true,
|
||||
}})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
|
||||
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
|
||||
assert.Error(t, err)
|
||||
|
||||
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Daily: true,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, notifications, 1)
|
||||
assert.Equal(t, "client-a", notifications[0].Client)
|
||||
assert.True(t, notifications[0].Enable)
|
||||
}
|
||||
+49
-28
@@ -172,7 +172,7 @@ func CompactRecord() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.DatabaseType == "sqlite" {
|
||||
if flags.IsSQLite() {
|
||||
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
|
||||
}
|
||||
//log.Printf("Record compaction completed")
|
||||
@@ -180,12 +180,19 @@ func CompactRecord() error {
|
||||
}
|
||||
|
||||
func migrateOldRecords(db *gorm.DB) error {
|
||||
// 计算 4 小时前的时间
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
return migrateOldRecordsAt(db, time.Now())
|
||||
}
|
||||
|
||||
func compactRecordCutoff(now time.Time) time.Time {
|
||||
return now.Add(-4 * time.Hour).Truncate(15 * time.Minute)
|
||||
}
|
||||
|
||||
func migrateOldRecordsAt(db *gorm.DB, now time.Time) error {
|
||||
cutoff := compactRecordCutoff(now)
|
||||
|
||||
// 查询 records 表中超过 4 小时的记录
|
||||
var records []models.Record
|
||||
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
|
||||
if err := db.Table("records").Where("time < ?", cutoff).Find(&records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -195,24 +202,29 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
|
||||
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
|
||||
type groupData struct {
|
||||
Cpu []float32
|
||||
Gpu []float32
|
||||
Load []float32
|
||||
Temp []float32
|
||||
Ram []int64
|
||||
RamTotal []int64
|
||||
Swap []int64
|
||||
SwapTotal []int64
|
||||
Disk []int64
|
||||
DiskTotal []int64
|
||||
NetIn []int64
|
||||
NetOut []int64
|
||||
NetTotalUp []int64
|
||||
NetTotalDown []int64
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
Cpu []float32
|
||||
Gpu []float32
|
||||
Load []float32
|
||||
Temp []float32
|
||||
Ram []int64
|
||||
RamTotal []int64
|
||||
Swap []int64
|
||||
SwapTotal []int64
|
||||
Disk []int64
|
||||
DiskTotal []int64
|
||||
NetIn []int64
|
||||
NetOut []int64
|
||||
NetTotalUp []int64
|
||||
NetTotalDown []int64
|
||||
TrafficUp int64
|
||||
TrafficDown int64
|
||||
LatestTime time.Time
|
||||
LatestTotalUp int64
|
||||
LatestTotalDown int64
|
||||
Process []int
|
||||
Connections []int
|
||||
ConnectionsUdp []int
|
||||
Uptime []int64
|
||||
}
|
||||
|
||||
groupedRecords := make(map[string]*groupData)
|
||||
@@ -236,6 +248,13 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
data.NetOut = append(data.NetOut, record.NetOut)
|
||||
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
|
||||
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
|
||||
data.TrafficUp += record.TrafficUp
|
||||
data.TrafficDown += record.TrafficDown
|
||||
if data.LatestTime.IsZero() || record.Time.ToTime().After(data.LatestTime) {
|
||||
data.LatestTime = record.Time.ToTime()
|
||||
data.LatestTotalUp = record.NetTotalUp
|
||||
data.LatestTotalDown = record.NetTotalDown
|
||||
}
|
||||
data.Process = append(data.Process, record.Process)
|
||||
data.Connections = append(data.Connections, record.Connections)
|
||||
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
|
||||
@@ -329,8 +348,10 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
|
||||
NetIn: getIntPercentile(data.NetIn, 0.2),
|
||||
NetOut: getIntPercentile(data.NetOut, 0.2),
|
||||
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
|
||||
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
|
||||
NetTotalUp: data.LatestTotalUp,
|
||||
NetTotalDown: data.LatestTotalDown,
|
||||
TrafficUp: data.TrafficUp,
|
||||
TrafficDown: data.TrafficDown,
|
||||
Process: getInt32Percentile(data.Process, high_percentile),
|
||||
Connections: getInt32Percentile(data.Connections, high_percentile),
|
||||
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
|
||||
@@ -350,7 +371,7 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// 删除 records 表中的旧数据
|
||||
if err := tx.Table("records").Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
|
||||
if err := tx.Table("records").Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -360,11 +381,11 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
|
||||
// migrateGPURecords 压缩GPU记录数据
|
||||
func migrateGPURecords(db *gorm.DB) error {
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
cutoff := compactRecordCutoff(time.Now())
|
||||
|
||||
// 查询超过4小时的GPU记录
|
||||
var gpuRecords []models.GPURecord
|
||||
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
|
||||
if err := db.Where("time < ?", cutoff).Find(&gpuRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -494,7 +515,7 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// 删除已压缩的原始GPU数据
|
||||
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,9 @@ var _ = func() bool {
|
||||
// then running migrateOldRecords and verifying the aggregation and cleanup.
|
||||
func TestCompactRecord(t *testing.T) {
|
||||
const totalMinutes = 12*60 + 30
|
||||
now := time.Now()
|
||||
threshold := now.Add(-4 * time.Hour)
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
threshold := compactRecordCutoff(now)
|
||||
overlapCutoff := threshold.Add(-1 * time.Hour)
|
||||
|
||||
// 使用 sqlite 内存数据库并迁移表结构
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -35,7 +36,7 @@ func TestCompactRecord(t *testing.T) {
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
expectedGroups := make(map[time.Time]struct{})
|
||||
expectedRemain := 0
|
||||
expectedRawRemain := 0
|
||||
|
||||
// 插入数据
|
||||
for i := 0; i < totalMinutes; i++ {
|
||||
@@ -45,10 +46,11 @@ func TestCompactRecord(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
if recTime.Before(threshold) {
|
||||
slot := recTime.Truncate(time.Hour)
|
||||
slot := recTime.Truncate(15 * time.Minute)
|
||||
expectedGroups[slot] = struct{}{}
|
||||
} else {
|
||||
expectedRemain++
|
||||
}
|
||||
if !recTime.Before(overlapCutoff) {
|
||||
expectedRawRemain++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ func TestCompactRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
// 运行压缩(迁移)逻辑
|
||||
err = migrateOldRecords(db)
|
||||
err = migrateOldRecordsAt(db, now)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证 long-term 表中的聚合记录数
|
||||
@@ -86,7 +88,7 @@ func TestCompactRecord(t *testing.T) {
|
||||
// 验证原始表中剩余记录数
|
||||
var remainCount int64
|
||||
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
|
||||
assert.Equal(t, int64(expectedRemain), remainCount+1)
|
||||
assert.Equal(t, int64(expectedRawRemain), remainCount)
|
||||
|
||||
// 导出压缩后的数据到 CSV
|
||||
var compRecs []models.Record
|
||||
@@ -128,3 +130,105 @@ func TestCompactRecord(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
|
||||
currentTime := time.Now().UTC().Truncate(time.Minute)
|
||||
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
records := []models.Record{
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now),
|
||||
NetTotalUp: 100,
|
||||
NetTotalDown: 200,
|
||||
TrafficUp: 0,
|
||||
TrafficDown: 0,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(5 * time.Minute)),
|
||||
NetTotalUp: 150,
|
||||
NetTotalDown: 260,
|
||||
TrafficUp: 50,
|
||||
TrafficDown: 60,
|
||||
},
|
||||
{
|
||||
Client: uuid,
|
||||
Time: models.FromTime(now.Add(10 * time.Minute)),
|
||||
NetTotalUp: 10,
|
||||
NetTotalDown: 30,
|
||||
TrafficUp: 10,
|
||||
TrafficDown: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
|
||||
assert.Len(t, compacted, 1)
|
||||
assert.Equal(t, int64(60), compacted[0].TrafficUp)
|
||||
assert.Equal(t, int64(90), compacted[0].TrafficDown)
|
||||
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
|
||||
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
|
||||
}
|
||||
|
||||
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
records := []models.Record{
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
|
||||
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
|
||||
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
|
||||
}
|
||||
for _, rec := range records {
|
||||
assert.NoError(t, db.Create(&rec).Error)
|
||||
}
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var remainTimes []models.Record
|
||||
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
|
||||
assert.Len(t, remainTimes, 2)
|
||||
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
|
||||
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
|
||||
}
|
||||
|
||||
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
|
||||
now := time.Date(2026, 6, 7, 12, 7, 0, 0, time.UTC)
|
||||
cutoff := compactRecordCutoff(now)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
|
||||
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
|
||||
assert.NoError(t, db.Create(&compactable).Error)
|
||||
assert.NoError(t, db.Create(&partialSlot).Error)
|
||||
|
||||
assert.NoError(t, migrateOldRecordsAt(db, now))
|
||||
|
||||
var compacted []models.Record
|
||||
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
|
||||
assert.Len(t, compacted, 1)
|
||||
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
|
||||
|
||||
var rawCount int64
|
||||
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
|
||||
assert.Equal(t, int64(1), rawCount)
|
||||
}
|
||||
@@ -200,41 +200,6 @@ func AddDefaultOnClientUUID(uuid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateAllClientsExpansion 启动时把旧版 default_on=true 且 clients 为空的任务展开为当前所有客户端 UUID。
|
||||
// 迁移后 clients 始终是显式列表,调度路径不再依赖 default_on。
|
||||
func MigrateAllClientsExpansion() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
var clients []models.Client
|
||||
if err := db.Select("uuid").Find(&clients).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return nil
|
||||
}
|
||||
allUUIDs := make(models.StringArray, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
if c.UUID != "" {
|
||||
allUUIDs = append(allUUIDs, c.UUID)
|
||||
}
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if len(task.Clients) > 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", allUUIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.PingRecord
|
||||
|
||||
+3
-3
@@ -8,13 +8,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (map[string]interface{}, error) {
|
||||
cstPtr, err := config.GetManyAs[config.Legacy]()
|
||||
cstPtr, err := config.GetManyAs[config.Settings]()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"oauth_provider": cst.OAuthProvider,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
|
||||
"record_enabled": cst.RecordEnabled,
|
||||
"record_preserve_time": cst.RecordPreserveTime,
|
||||
"ping_record_preserve_time": cst.PingRecordPreserveTime,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
|
||||
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/public/defaultTheme`。
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/public/defaultTheme`。
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
|
||||
@@ -25,63 +25,11 @@ var (
|
||||
db *gorm.DB
|
||||
SetDb = func(gdb *gorm.DB) {
|
||||
db = gdb
|
||||
migrateInPlace()
|
||||
}
|
||||
)
|
||||
|
||||
func migrateInPlace() {
|
||||
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
|
||||
slog.Info("[>1.1.4] Moving legacy config data...")
|
||||
|
||||
var oldData Legacy
|
||||
if err := db.Order("id desc").First(&oldData).Error; err != nil {
|
||||
db.Migrator().DropTable("configs")
|
||||
} else {
|
||||
var newRows []ConfigItem
|
||||
val := reflect.ValueOf(oldData)
|
||||
typ := reflect.TypeOf(oldData)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
key := strings.Split(tag, ",")[0]
|
||||
|
||||
// 过滤 id 和无用字段
|
||||
if key == "" || key == "-" || key == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
valInterface := val.Field(i).Interface()
|
||||
jsonBytes, _ := json.Marshal(valInterface)
|
||||
|
||||
newRows = append(newRows, ConfigItem{
|
||||
Key: key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newRows) > 0 {
|
||||
return tx.Create(&newRows).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed " + err.Error())
|
||||
}
|
||||
return
|
||||
if err := db.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
panic("failed to migrate config item table: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
db.AutoMigrate(&ConfigItem{})
|
||||
}
|
||||
)
|
||||
|
||||
// Get 获取原始值 (反序列化为 interface{})
|
||||
func Get(key string, defaul ...any) (any, error) {
|
||||
@@ -601,8 +549,8 @@ func SetMany(cst map[string]any) error {
|
||||
}
|
||||
|
||||
type ConfigEvent struct {
|
||||
Old map[string]any // Old models.Config
|
||||
New map[string]any // New models.Config
|
||||
Old map[string]any
|
||||
New map[string]any
|
||||
}
|
||||
|
||||
func (e ConfigEvent) IsChanged(key string) bool {
|
||||
@@ -2,19 +2,22 @@ package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Legacy struct {
|
||||
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:""`
|
||||
type Settings struct {
|
||||
ID uint `json:"id,omitempty"` // 1
|
||||
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
|
||||
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
|
||||
CorsOriginCheckEnabled bool `json:"cors_origin_check_enabled" default:"true"` // 是否启用 API CORS 跨域请求校验,默认 true
|
||||
CorsAllowedOrigins string `json:"cors_allowed_origins" default:""` // API 跨域允许列表
|
||||
WsOriginCheckEnabled bool `json:"ws_origin_check_enabled" default:"true"` // 是否校验 WebSocket Origin
|
||||
WsAllowedOrigins string `json:"ws_allowed_origins" default:""` // WebSocket Origin 允许列表
|
||||
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" default:"true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
|
||||
@@ -47,7 +50,10 @@ type Legacy struct {
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
AllowCorsKey = "allow_cors"
|
||||
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
|
||||
CorsAllowedOriginsKey = "cors_allowed_origins"
|
||||
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
|
||||
WsAllowedOriginsKey = "ws_allowed_origins"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
@@ -77,57 +83,5 @@ const (
|
||||
RecordPreserveTimeKey = "record_preserve_time"
|
||||
PingRecordPreserveTimeKey = "ping_record_preserve_time"
|
||||
UpdatedAtKey = "updated_at"
|
||||
XtermjsSettingsKey = "xtermjs_settings"
|
||||
)
|
||||
|
||||
func (Legacy) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
// Decrepted
|
||||
/*
|
||||
func Update(cst map[string]interface{}) error {
|
||||
oldConfig, _ := GetManyAs[Legacy]()
|
||||
// Proceed with update
|
||||
cst["updated_at"] = time.Now().Unix()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
err := Db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
//publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,345 @@
|
||||
package corn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Func 是 corn 调度器执行的任务函数。
|
||||
// 调度器会为每次执行传入可取消的 context,便于任务在重载或关闭时尽快退出。
|
||||
type Func func(ctx context.Context)
|
||||
|
||||
type job struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type schedule interface {
|
||||
Next(time.Time) time.Time
|
||||
}
|
||||
|
||||
type everySchedule struct {
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func (s everySchedule) Next(t time.Time) time.Time {
|
||||
return t.Add(s.interval)
|
||||
}
|
||||
|
||||
type cronSchedule struct {
|
||||
seconds map[int]struct{}
|
||||
minutes map[int]struct{}
|
||||
hours map[int]struct{}
|
||||
dom map[int]struct{}
|
||||
months map[int]struct{}
|
||||
dow map[int]struct{}
|
||||
}
|
||||
|
||||
func (s cronSchedule) Next(t time.Time) time.Time {
|
||||
next := t.Truncate(time.Second).Add(time.Second)
|
||||
limit := next.Add(366 * 24 * time.Hour)
|
||||
for next.Before(limit) {
|
||||
if s.match(next) {
|
||||
return next
|
||||
}
|
||||
next = next.Add(time.Second)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (s cronSchedule) match(t time.Time) bool {
|
||||
_, okSecond := s.seconds[t.Second()]
|
||||
_, okMinute := s.minutes[t.Minute()]
|
||||
_, okHour := s.hours[t.Hour()]
|
||||
_, okDay := s.dom[t.Day()]
|
||||
_, okMonth := s.months[int(t.Month())]
|
||||
_, okWeek := s.dow[int(t.Weekday())]
|
||||
return okSecond && okMinute && okHour && okDay && okMonth && okWeek
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
jobs map[string]job
|
||||
}
|
||||
|
||||
var defaultManager = NewManager()
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{jobs: make(map[string]job)}
|
||||
}
|
||||
|
||||
// AddFunc 按 cron 表达式注册一个任务,fn 会在独立 goroutine 中执行。
|
||||
// 支持 5 字段、6 字段 cron 表达式,以及 @every 1m 这类固定间隔表达式。
|
||||
func AddFunc(name string, spec string, fn func()) error {
|
||||
return AddContextFunc(name, spec, false, func(context.Context) { fn() })
|
||||
}
|
||||
|
||||
// AddContextFunc 按 cron 表达式注册一个任务,支持传递带 context 的 func。
|
||||
func AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
|
||||
return defaultManager.AddContextFunc(name, spec, runImmediately, fn)
|
||||
}
|
||||
|
||||
func Every(duration time.Duration) string {
|
||||
return "@every " + duration.String()
|
||||
}
|
||||
|
||||
func Remove(name string) {
|
||||
defaultManager.Remove(name)
|
||||
}
|
||||
|
||||
func RemovePrefix(prefix string) {
|
||||
defaultManager.RemovePrefix(prefix)
|
||||
}
|
||||
|
||||
func StopAll() {
|
||||
defaultManager.StopAll()
|
||||
}
|
||||
|
||||
func (m *Manager) AddFunc(name string, spec string, fn func()) error {
|
||||
return m.AddContextFunc(name, spec, false, func(context.Context) { fn() })
|
||||
}
|
||||
|
||||
func (m *Manager) AddContextFunc(name string, spec string, runImmediately bool, fn Func) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("corn job name is empty")
|
||||
}
|
||||
s, err := Parse(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("corn job %q spec is invalid: %w", name, err)
|
||||
}
|
||||
if fn == nil {
|
||||
return fmt.Errorf("corn job %q func is nil", name)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.replace(name, cancel)
|
||||
|
||||
go m.run(ctx, name, s, runImmediately, fn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if old, ok := m.jobs[name]; ok {
|
||||
old.cancel()
|
||||
delete(m.jobs, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) RemovePrefix(prefix string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, old := range m.jobs {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
old.cancel()
|
||||
delete(m.jobs, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, old := range m.jobs {
|
||||
old.cancel()
|
||||
delete(m.jobs, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) replace(name string, cancel context.CancelFunc) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if old, ok := m.jobs[name]; ok {
|
||||
old.cancel()
|
||||
}
|
||||
m.jobs[name] = job{cancel: cancel}
|
||||
}
|
||||
|
||||
func (m *Manager) run(ctx context.Context, name string, s schedule, runImmediately bool, fn Func) {
|
||||
if runImmediately {
|
||||
go safeRun(ctx, name, fn)
|
||||
}
|
||||
|
||||
nextTick := s.Next(time.Now())
|
||||
if nextTick.IsZero() {
|
||||
log.Printf("corn job %s has no next run time", name)
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(time.Until(nextTick))
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
go safeRun(ctx, name, fn)
|
||||
nextTick = s.Next(nextTick)
|
||||
if nextTick.IsZero() {
|
||||
return
|
||||
}
|
||||
resetTimer(timer, time.Until(nextTick))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetTimer(timer *time.Timer, duration time.Duration) {
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
timer.Reset(duration)
|
||||
}
|
||||
|
||||
func safeRun(ctx context.Context, name string, fn Func) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("corn job %s panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
fn(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse 解析 corn 表达式。
|
||||
// 支持:
|
||||
// - 5 字段:minute hour day-of-month month day-of-week
|
||||
// - 6 字段:second minute hour day-of-month month day-of-week
|
||||
// - @every 1m / @every 30s
|
||||
//
|
||||
// 字段支持 *、*/n、a-b、a-b/n、逗号列表和具体数字。
|
||||
func Parse(spec string) (schedule, error) {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil, fmt.Errorf("empty spec")
|
||||
}
|
||||
if strings.HasPrefix(spec, "@every ") {
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(spec, "@every ")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if duration <= 0 {
|
||||
return nil, fmt.Errorf("@every duration must be positive")
|
||||
}
|
||||
return everySchedule{interval: duration}, nil
|
||||
}
|
||||
|
||||
fields := strings.Fields(spec)
|
||||
if len(fields) == 5 {
|
||||
fields = append([]string{"0"}, fields...)
|
||||
}
|
||||
if len(fields) != 6 {
|
||||
return nil, fmt.Errorf("expected 5 or 6 fields, got %d", len(fields))
|
||||
}
|
||||
|
||||
seconds, err := parseField(fields[0], 0, 59)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("second: %w", err)
|
||||
}
|
||||
minutes, err := parseField(fields[1], 0, 59)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
hours, err := parseField(fields[2], 0, 23)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
dom, err := parseField(fields[3], 1, 31)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("day-of-month: %w", err)
|
||||
}
|
||||
months, err := parseField(fields[4], 1, 12)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
dow, err := parseField(fields[5], 0, 7)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("day-of-week: %w", err)
|
||||
}
|
||||
if _, ok := dow[7]; ok {
|
||||
dow[0] = struct{}{}
|
||||
delete(dow, 7)
|
||||
}
|
||||
|
||||
return cronSchedule{seconds: seconds, minutes: minutes, hours: hours, dom: dom, months: months, dow: dow}, nil
|
||||
}
|
||||
|
||||
func parseField(field string, min int, max int) (map[int]struct{}, error) {
|
||||
values := make(map[int]struct{})
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("empty part")
|
||||
}
|
||||
|
||||
base := part
|
||||
step := 1
|
||||
if strings.Contains(part, "/") {
|
||||
parts := strings.Split(part, "/")
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid step %q", part)
|
||||
}
|
||||
base = parts[0]
|
||||
parsedStep, err := strconv.Atoi(parts[1])
|
||||
if err != nil || parsedStep <= 0 {
|
||||
return nil, fmt.Errorf("invalid step %q", parts[1])
|
||||
}
|
||||
step = parsedStep
|
||||
}
|
||||
|
||||
start, end, err := parseRange(base, min, max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := start; i <= end; i += step {
|
||||
values[i] = struct{}{}
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parseRange(base string, min int, max int) (int, int, error) {
|
||||
if base == "*" || base == "" {
|
||||
return min, max, nil
|
||||
}
|
||||
if strings.Contains(base, "-") {
|
||||
parts := strings.Split(base, "-")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid range %q", base)
|
||||
}
|
||||
start, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid range start %q", parts[0])
|
||||
}
|
||||
end, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid range end %q", parts[1])
|
||||
}
|
||||
if start > end || start < min || end > max {
|
||||
return 0, 0, fmt.Errorf("range %q out of bounds %d-%d", base, min, max)
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(base)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid value %q", base)
|
||||
}
|
||||
if value < min || value > max {
|
||||
return 0, 0, fmt.Errorf("value %d out of bounds %d-%d", value, min, max)
|
||||
}
|
||||
return value, value, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package migrations
|
||||
|
||||
import "time"
|
||||
|
||||
// ClientInfo is the legacy table shape for migrating pre-Client model data.
|
||||
type ClientInfo struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
|
||||
Name string `json:"name" gorm:"type:varchar(100);not null"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/pkg/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type legacyModelConfig struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"`
|
||||
PrivateSite bool `json:"private_site" gorm:"default: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"`
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"`
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"`
|
||||
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"`
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
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"`
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"`
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"`
|
||||
RecordEnabled bool `json:"record_enabled" gorm:"default:true"`
|
||||
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"`
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"`
|
||||
CreatedAt models.LocalTime
|
||||
UpdatedAt models.LocalTime
|
||||
}
|
||||
|
||||
func (legacyModelConfig) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
type legacyConfig struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
Sitename string `json:"sitename"`
|
||||
Description string `json:"description"`
|
||||
Theme string `json:"theme"`
|
||||
PrivateSite bool `json:"private_site"`
|
||||
ApiKey string `json:"api_key"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key"`
|
||||
ScriptDomain string `json:"script_domain"`
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest"`
|
||||
EulaAccepted bool `json:"eula_accepted"`
|
||||
BaseScriptsURLKey string `json:"base_scripts_url"`
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled"`
|
||||
GeoIpProvider string `json:"geo_ip_provider"`
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled"`
|
||||
OAuthProvider string `json:"o_auth_provider"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login"`
|
||||
CloudflareTunnelToken string `json:"cloudflare_tunnel_token"`
|
||||
CustomHead string `json:"custom_head"`
|
||||
CustomBody string `json:"custom_body"`
|
||||
NotificationEnabled bool `json:"notification_enabled"`
|
||||
NotificationMethod string `json:"notification_method"`
|
||||
NotificationTemplate string `json:"notification_template"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled"`
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"`
|
||||
LoginNotification bool `json:"login_notification"`
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"`
|
||||
RecordEnabled bool `json:"record_enabled"`
|
||||
RecordPreserveTime int `json:"record_preserve_time"`
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (legacyConfig) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
type legacyPingTask struct {
|
||||
Id uint `gorm:"column:id"`
|
||||
Clients string `gorm:"column:clients"`
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// Run executes one-shot startup migrations before current runtime paths are used.
|
||||
func Run(ctx Context) error {
|
||||
db := ctx.DB
|
||||
if db == nil {
|
||||
return fmt.Errorf("migration database is nil")
|
||||
}
|
||||
|
||||
legacyConfigTable := hasLegacyConfigTable(db)
|
||||
|
||||
if legacyConfigTable {
|
||||
if err := migrateLegacyOidcConfig(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyMessageSenderConfig(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateLegacyClientInfo(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyLoadNotification(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyConfigTable {
|
||||
if err := migrateLegacyConfigToItems(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasLegacyConfigTable(db *gorm.DB) bool {
|
||||
if !db.Migrator().HasTable("configs") {
|
||||
return false
|
||||
}
|
||||
return hasTableColumn(db, "configs", "id") ||
|
||||
hasTableColumn(db, "configs", "sitename")
|
||||
}
|
||||
|
||||
func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
|
||||
columns, err := db.Migrator().ColumnTypes(tableName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, column := range columns {
|
||||
if strings.EqualFold(column.Name(), columnName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func migrateLegacyLoadNotification(db *gorm.DB) error {
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
return db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyOidcConfig(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&models.OidcProvider{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[>1.0.2] Merge OidcProvider table....")
|
||||
var oldData struct {
|
||||
OAuthClientID string `gorm:"column:o_auth_client_id"`
|
||||
OAuthClientSecret string `gorm:"column:o_auth_client_secret"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
|
||||
return fmt.Errorf("get legacy OIDC config: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.OidcProvider{}); err != nil {
|
||||
return err
|
||||
}
|
||||
addition, err := json.Marshal(map[string]string{
|
||||
"client_id": oldData.OAuthClientID,
|
||||
"client_secret": oldData.OAuthClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal legacy OIDC config: %w", err)
|
||||
}
|
||||
if err := db.Save(&models.OidcProvider{
|
||||
Name: "github",
|
||||
Addition: string(addition),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&legacyModelConfig{}).Where("id = 1").Update("o_auth_provider", "github").Error
|
||||
}
|
||||
|
||||
func migrateLegacyMessageSenderConfig(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&models.MessageSenderProvider{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[>1.0.2] Migrate MessageSender configuration....")
|
||||
var oldData struct {
|
||||
TelegramBotToken string `gorm:"column:telegram_bot_token"`
|
||||
TelegramChatID string `gorm:"column:telegram_chat_id"`
|
||||
TelegramEndpoint string `gorm:"column:telegram_endpoint"`
|
||||
EmailHost string `gorm:"column:email_host"`
|
||||
EmailPort int `gorm:"column:email_port"`
|
||||
EmailUsername string `gorm:"column:email_username"`
|
||||
EmailPassword string `gorm:"column:email_password"`
|
||||
EmailSender string `gorm:"column:email_sender"`
|
||||
EmailReceiver string `gorm:"column:email_receiver"`
|
||||
EmailUseSSL bool `gorm:"column:email_use_ssl"`
|
||||
NotificationMethod string `gorm:"column:notification_method"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&oldData).Error; err != nil {
|
||||
return fmt.Errorf("get legacy message sender config: %w", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.MessageSenderProvider{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldData.NotificationMethod == "telegram" && oldData.TelegramBotToken != "" {
|
||||
telegramConfig := map[string]interface{}{
|
||||
"bot_token": oldData.TelegramBotToken,
|
||||
"chat_id": oldData.TelegramChatID,
|
||||
"endpoint": oldData.TelegramEndpoint,
|
||||
}
|
||||
if telegramConfig["endpoint"] == "" {
|
||||
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
|
||||
}
|
||||
if err := saveLegacyMessageSenderConfig(db, "telegram", telegramConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if oldData.NotificationMethod == "email" && oldData.EmailHost != "" {
|
||||
emailConfig := map[string]interface{}{
|
||||
"host": oldData.EmailHost,
|
||||
"port": oldData.EmailPort,
|
||||
"username": oldData.EmailUsername,
|
||||
"password": oldData.EmailPassword,
|
||||
"sender": oldData.EmailSender,
|
||||
"receiver": oldData.EmailReceiver,
|
||||
"use_ssl": oldData.EmailUseSSL,
|
||||
}
|
||||
if err := saveLegacyMessageSenderConfig(db, "email", emailConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{
|
||||
"telegram_bot_token",
|
||||
"telegram_chat_id",
|
||||
"telegram_endpoint",
|
||||
"email_host",
|
||||
"email_port",
|
||||
"email_username",
|
||||
"email_password",
|
||||
"email_sender",
|
||||
"email_receiver",
|
||||
"email_use_ssl",
|
||||
} {
|
||||
if hasTableColumn(db, "configs", column) {
|
||||
if err := db.Migrator().DropColumn(&legacyModelConfig{}, column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveLegacyMessageSenderConfig(db *gorm.DB, name string, config map[string]interface{}) error {
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal legacy %s message sender config: %w", name, err)
|
||||
}
|
||||
return db.Save(&models.MessageSenderProvider{
|
||||
Name: name,
|
||||
Addition: string(configJSON),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func migrateLegacyConfigToItems(db *gorm.DB) error {
|
||||
log.Println("[>1.1.4] Moving legacy config data...")
|
||||
|
||||
var oldData legacyConfig
|
||||
if err := db.Order("id desc").First(&oldData).Error; err != nil {
|
||||
if err := db.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.AutoMigrate(&appconfig.ConfigItem{})
|
||||
}
|
||||
|
||||
newRows, err := legacyConfigRows(oldData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newRows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&newRows).Error
|
||||
})
|
||||
}
|
||||
|
||||
func legacyConfigRows(oldData legacyConfig) ([]appconfig.ConfigItem, error) {
|
||||
val := reflect.ValueOf(oldData)
|
||||
typ := reflect.TypeOf(oldData)
|
||||
newRows := make([]appconfig.ConfigItem, 0, val.NumField())
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
key := strings.Split(tag, ",")[0]
|
||||
if key == "" || key == "-" || key == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(val.Field(i).Interface())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal legacy config %s: %w", key, err)
|
||||
}
|
||||
newRows = append(newRows, appconfig.ConfigItem{
|
||||
Key: key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
|
||||
return newRows, nil
|
||||
}
|
||||
|
||||
func migrateLegacyClientInfo(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("client_infos") {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
|
||||
if err := db.AutoMigrate(&models.Client{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var clientInfos []ClientInfo
|
||||
if err := db.Find(&clientInfos).Error; err != nil {
|
||||
return fmt.Errorf("read legacy ClientInfo table: %w", err)
|
||||
}
|
||||
|
||||
for _, info := range clientInfos {
|
||||
var client models.Client
|
||||
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
|
||||
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
client.Name = info.Name
|
||||
client.CpuName = info.CpuName
|
||||
client.Virtualization = info.Virtualization
|
||||
client.Arch = info.Arch
|
||||
client.CpuCores = info.CpuCores
|
||||
client.OS = info.OS
|
||||
client.GpuName = info.GpuName
|
||||
client.IPv4 = info.IPv4
|
||||
client.IPv6 = info.IPv6
|
||||
client.Region = info.Region
|
||||
client.Remark = info.Remark
|
||||
client.PublicRemark = info.PublicRemark
|
||||
client.MemTotal = info.MemTotal
|
||||
client.SwapTotal = info.SwapTotal
|
||||
client.DiskTotal = info.DiskTotal
|
||||
client.Version = info.Version
|
||||
client.Weight = info.Weight
|
||||
client.Price = info.Price
|
||||
client.BillingCycle = info.BillingCycle
|
||||
client.ExpiredAt = models.FromTime(info.ExpiredAt)
|
||||
if err := db.Save(&client).Error; err != nil {
|
||||
return fmt.Errorf("update Client record %s: %w", info.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
|
||||
return fmt.Errorf("backup legacy ClientInfo table: %w", err)
|
||||
}
|
||||
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyPingAllClientsExpansion(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&models.PingTask{}) || !db.Migrator().HasTable(&models.Client{}) {
|
||||
return nil
|
||||
}
|
||||
if !hasTableColumn(db, "ping_tasks", "all_clients") {
|
||||
return nil
|
||||
}
|
||||
if !hasTableColumn(db, "ping_tasks", "clients") {
|
||||
if err := db.Exec("ALTER TABLE ping_tasks ADD COLUMN clients text").Error; err != nil {
|
||||
return fmt.Errorf("add clients column for legacy ping task expansion: %w", err)
|
||||
}
|
||||
}
|
||||
if err := db.Table("ping_tasks").
|
||||
Where("clients IS NULL OR clients = '' OR clients = '[]' OR clients = 'null'").
|
||||
Update("clients", models.StringArray{}).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy ping task clients: %w", err)
|
||||
}
|
||||
|
||||
var pingTasks []legacyPingTask
|
||||
if err := db.Table("ping_tasks").Select("id, clients").Where("all_clients = ?", true).Scan(&pingTasks).Error; err != nil {
|
||||
return fmt.Errorf("find legacy all_clients ping tasks: %w", err)
|
||||
}
|
||||
if len(pingTasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var clients []models.Client
|
||||
if err := db.Select("uuid").Find(&clients).Error; err != nil {
|
||||
return fmt.Errorf("find clients for legacy ping task expansion: %w", err)
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
allUUIDs := make(models.StringArray, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if client.UUID != "" {
|
||||
allUUIDs = append(allUUIDs, client.UUID)
|
||||
}
|
||||
}
|
||||
if len(allUUIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, task := range pingTasks {
|
||||
if !isLegacyPingClientsEmpty(task.Clients) {
|
||||
continue
|
||||
}
|
||||
if err := db.Table("ping_tasks").Where("id = ?", task.Id).Update("clients", allUUIDs).Error; err != nil {
|
||||
return fmt.Errorf("expand legacy all_clients ping task %d: %w", task.Id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLegacyPingClientsEmpty(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" {
|
||||
return true
|
||||
}
|
||||
var clients []string
|
||||
if err := json.Unmarshal([]byte(raw), &clients); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(clients) == 0
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/pkg/config"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + strings.ReplaceAll(name, " ", "_") + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite test db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sqlite test db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestHasLegacyConfigTable(t *testing.T) {
|
||||
t.Run("config item table", func(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_config_item")
|
||||
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
t.Fatalf("migrate config item table: %v", err)
|
||||
}
|
||||
if hasLegacyConfigTable(db) {
|
||||
t.Fatal("config item table was detected as legacy config table")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy config table", func(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_legacy_config")
|
||||
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
|
||||
t.Fatalf("migrate legacy config table: %v", err)
|
||||
}
|
||||
if !hasLegacyConfigTable(db) {
|
||||
t.Fatal("legacy config table was not detected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunSkipsLegacyConfigMigrationForCurrentConfigItemTable(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_config_item_run")
|
||||
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
|
||||
t.Fatalf("migrate config item table: %v", err)
|
||||
}
|
||||
if err := db.Create(&appconfig.ConfigItem{Key: "o_auth_provider", Value: `"github"`}).Error; err != nil {
|
||||
t.Fatalf("seed config item: %v", err)
|
||||
}
|
||||
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn(&legacyModelConfig{}, "id") {
|
||||
t.Fatal("config item table was changed into the legacy config shape")
|
||||
}
|
||||
if db.Migrator().HasTable(&models.OidcProvider{}) {
|
||||
t.Fatal("legacy OIDC migration ran against the config item table")
|
||||
}
|
||||
|
||||
var item appconfig.ConfigItem
|
||||
if err := db.First(&item, "key = ?", "o_auth_provider").Error; err != nil {
|
||||
t.Fatalf("config item was not preserved: %v", err)
|
||||
}
|
||||
if item.Value != `"github"` {
|
||||
t.Fatalf("unexpected config value: %s", item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesVersion120RuntimeShape(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_v120_runtime_shape")
|
||||
if err := db.AutoMigrate(
|
||||
&appconfig.ConfigItem{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.Client{},
|
||||
&models.PingTask{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate 1.2.0 runtime shape: %v", err)
|
||||
}
|
||||
now := models.FromTime(time.Now())
|
||||
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.PingTask{
|
||||
Name: "already explicit",
|
||||
Clients: models.StringArray{"client-a"},
|
||||
DefaultOn: true,
|
||||
Type: "icmp",
|
||||
Target: "example.com",
|
||||
Interval: 60,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed ping task: %v", err)
|
||||
}
|
||||
if err := db.Create(&appconfig.ConfigItem{Key: appconfig.OAuthProviderKey, Value: `"github"`}).Error; err != nil {
|
||||
t.Fatalf("seed config item: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.OidcProvider{Name: "github", Addition: `{"client_id":"old","client_secret":"secret"}`}).Error; err != nil {
|
||||
t.Fatalf("seed oidc provider: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.MessageSenderProvider{Name: "telegram", Addition: `{"bot_token":"old-token"}`}).Error; err != nil {
|
||||
t.Fatalf("seed message sender provider: %v", err)
|
||||
}
|
||||
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
|
||||
t.Fatal("current config item table was treated as legacy wide config")
|
||||
}
|
||||
|
||||
var configItem appconfig.ConfigItem
|
||||
if err := db.First(&configItem, "key = ?", appconfig.OAuthProviderKey).Error; err != nil {
|
||||
t.Fatalf("find config item: %v", err)
|
||||
}
|
||||
if configItem.Value != `"github"` {
|
||||
t.Fatalf("unexpected config item value: %s", configItem.Value)
|
||||
}
|
||||
|
||||
var oidc models.OidcProvider
|
||||
if err := db.First(&oidc, "name = ?", "github").Error; err != nil {
|
||||
t.Fatalf("find oidc provider: %v", err)
|
||||
}
|
||||
if oidc.Addition != `{"client_id":"old","client_secret":"secret"}` {
|
||||
t.Fatalf("oidc provider was unexpectedly changed: %s", oidc.Addition)
|
||||
}
|
||||
|
||||
var sender models.MessageSenderProvider
|
||||
if err := db.First(&sender, "name = ?", "telegram").Error; err != nil {
|
||||
t.Fatalf("find message sender provider: %v", err)
|
||||
}
|
||||
if sender.Addition != `{"bot_token":"old-token"}` {
|
||||
t.Fatalf("message sender provider was unexpectedly changed: %s", sender.Addition)
|
||||
}
|
||||
|
||||
var task models.PingTask
|
||||
if err := db.First(&task, "name = ?", "already explicit").Error; err != nil {
|
||||
t.Fatalf("find ping task: %v", err)
|
||||
}
|
||||
if len(task.Clients) != 1 || task.Clients[0] != "client-a" {
|
||||
t.Fatalf("explicit ping task clients were changed: %v", task.Clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigratesLegacyConfigTableToConfigItems(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_legacy_config_to_items")
|
||||
if err := db.AutoMigrate(&legacyModelConfig{}); err != nil {
|
||||
t.Fatalf("migrate legacy config table: %v", err)
|
||||
}
|
||||
legacy := legacyModelConfig{
|
||||
Sitename: "Old Komari",
|
||||
Description: "legacy description",
|
||||
Theme: "classic",
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ip-api",
|
||||
OAuthProvider: "github",
|
||||
NotificationMethod: "none",
|
||||
TrafficLimitPercentage: 66.5,
|
||||
RecordEnabled: true,
|
||||
RecordPreserveTime: 48,
|
||||
PingRecordPreserveTime: 12,
|
||||
ExpireNotificationLeadDays: 3,
|
||||
}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
t.Fatalf("seed legacy config: %v", err)
|
||||
}
|
||||
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn(&legacyModelConfig{}, "sitename") {
|
||||
t.Fatal("legacy config columns were not removed")
|
||||
}
|
||||
|
||||
var sitename appconfig.ConfigItem
|
||||
if err := db.First(&sitename, "key = ?", appconfig.SitenameKey).Error; err != nil {
|
||||
t.Fatalf("find migrated sitename: %v", err)
|
||||
}
|
||||
if sitename.Value != `"Old Komari"` {
|
||||
t.Fatalf("unexpected sitename value: %s", sitename.Value)
|
||||
}
|
||||
|
||||
var corsOriginCheck appconfig.ConfigItem
|
||||
if err := db.First(&corsOriginCheck, "key = ?", appconfig.CorsOriginCheckEnabledKey).Error; err == nil {
|
||||
t.Fatalf("unexpected migrated cors_origin_check_enabled value: %s", corsOriginCheck.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExpandsLegacyPingAllClientsTasks(t *testing.T) {
|
||||
db := openTestDB(t, "migrations_ping_all_clients")
|
||||
if err := db.AutoMigrate(&models.Client{}); err != nil {
|
||||
t.Fatalf("migrate clients: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE ping_tasks (
|
||||
id integer primary key autoincrement,
|
||||
weight integer not null default 0,
|
||||
name varchar(255) not null,
|
||||
all_clients boolean not null default false,
|
||||
type varchar(12) not null default 'icmp',
|
||||
target varchar(255) not null,
|
||||
interval integer not null default 60
|
||||
)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy ping_tasks: %v", err)
|
||||
}
|
||||
now := models.FromTime(time.Now())
|
||||
clients := []models.Client{
|
||||
{UUID: "client-a", Token: "token-a", CreatedAt: now, UpdatedAt: now},
|
||||
{UUID: "client-b", Token: "token-b", CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(&clients).Error; err != nil {
|
||||
t.Fatalf("seed clients: %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO ping_tasks (name, all_clients, type, target, interval) VALUES (?, ?, ?, ?, ?)", "legacy task", true, "icmp", "example.com", 60).Error; err != nil {
|
||||
t.Fatalf("seed legacy ping task: %v", err)
|
||||
}
|
||||
|
||||
if err := Run(Context{DB: db}); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
var task models.PingTask
|
||||
if err := db.First(&task).Error; err != nil {
|
||||
t.Fatalf("find migrated ping task: %v", err)
|
||||
}
|
||||
if len(task.Clients) != 2 {
|
||||
t.Fatalf("expected two migrated clients, got %v", task.Clients)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, uuid := range task.Clients {
|
||||
got[uuid] = true
|
||||
}
|
||||
if !got["client-a"] || !got["client-b"] {
|
||||
t.Fatalf("unexpected migrated clients: %v", task.Clients)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(task.Clients)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal migrated clients: %v", err)
|
||||
}
|
||||
if string(raw) != `["client-a","client-b"]` {
|
||||
t.Fatalf("unexpected clients json: %s", raw)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,87 @@
|
||||
package v1
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type IPAddress struct {
|
||||
Ipv4 string `json:"ipv4"`
|
||||
Ipv6 string `json:"ipv6"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
CPU CPUReport `json:"cpu"`
|
||||
Ram RamReport `json:"ram"`
|
||||
Swap RamReport `json:"swap"`
|
||||
Load LoadReport `json:"load"`
|
||||
Disk DiskReport `json:"disk"`
|
||||
Network NetworkReport `json:"network"`
|
||||
Connections ConnectionsReport `json:"connections"`
|
||||
GPU *GPUDetailReport `json:"gpu,omitempty"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Process int `json:"process"`
|
||||
Message string `json:"message"`
|
||||
Method string `json:"method,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type GPUDetailReport struct {
|
||||
Count int `json:"count"`
|
||||
AverageUsage float64 `json:"average_usage"`
|
||||
DetailedInfo []GPUDeviceInfo `json:"detailed_info"`
|
||||
}
|
||||
|
||||
type GPUDeviceInfo struct {
|
||||
Name string `json:"name"`
|
||||
MemoryTotal int64 `json:"memory_total"`
|
||||
MemoryUsed int64 `json:"memory_used"`
|
||||
Utilization float64 `json:"utilization"`
|
||||
Temperature int `json:"temperature"`
|
||||
}
|
||||
|
||||
// GPUReport is the legacy v1 single-GPU shape kept for wire compatibility.
|
||||
type GPUReport struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Usage float64 `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type RamReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type LoadReport struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
|
||||
type DiskReport struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
}
|
||||
|
||||
type NetworkReport struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
TotalUp int64 `json:"totalUp"`
|
||||
TotalDown int64 `json:"totalDown"`
|
||||
}
|
||||
|
||||
type ConnectionsReport struct {
|
||||
TCP int `json:"tcp"`
|
||||
UDP int `json:"udp"`
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package v2
|
||||
|
||||
import v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
|
||||
const (
|
||||
Version = "2.0"
|
||||
MethodAgentReport = "agent.report"
|
||||
MethodAgentBasicInfo = "agent.basicInfo"
|
||||
MethodAgentPingResult = "agent.pingResult"
|
||||
MethodAgentTaskResult = "agent.taskResult"
|
||||
MethodAgentExec = "agent.exec"
|
||||
MethodAgentPing = "agent.ping"
|
||||
MethodAgentMessage = "agent.message"
|
||||
MethodAgentEvent = "agent.event"
|
||||
MethodAgentTerminal = "agent.terminal.request"
|
||||
MethodAgentPull = "agent.pull"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
ID any `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *RPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type ReportParams struct {
|
||||
Report v1.Report `json:"report"`
|
||||
AckEventIDs []string `json:"ack_event_ids,omitempty"`
|
||||
}
|
||||
|
||||
type BasicInfoParams struct {
|
||||
Info map[string]interface{} `json:"info"`
|
||||
}
|
||||
|
||||
type PingResultParams struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
PingType string `json:"ping_type"`
|
||||
Value int `json:"value"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
}
|
||||
|
||||
type PullParams struct {
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
AckEventIDs []string `json:"ack_event_ids,omitempty"`
|
||||
LastEventID string `json:"last_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ExecParams struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type PingParams struct {
|
||||
TaskID uint `json:"ping_task_id"`
|
||||
Type string `json:"ping_type"`
|
||||
Target string `json:"ping_target"`
|
||||
}
|
||||
|
||||
type MessageParams struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type EventParams struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type TerminalRequestParams struct {
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
func Success(id any, result any) Response {
|
||||
return Response{JSONRPC: Version, ID: id, Result: result}
|
||||
}
|
||||
|
||||
func Error(id any, code int, message string, data any) Response {
|
||||
return Response{JSONRPC: Version, ID: id, Error: &RPCError{Code: code, Message: message, Data: data}}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package v2
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const (
|
||||
MethodNetworkTestNextTrace = "networkTest.nextTrace"
|
||||
MethodNetworkTestIperf3 = "networkTest.iperf3"
|
||||
MethodNetworkTestMeshTrace = "networkTest.meshTrace"
|
||||
MethodNetworkTestGetMeshTraceJob = "networkTest.getMeshTraceJob"
|
||||
)
|
||||
|
||||
type IPFamily string
|
||||
|
||||
const (
|
||||
IPFamilyAuto IPFamily = "auto"
|
||||
IPFamilyIPv4 IPFamily = "ipv4"
|
||||
IPFamilyIPv6 IPFamily = "ipv6"
|
||||
)
|
||||
|
||||
type TraceProtocol string
|
||||
|
||||
const (
|
||||
TraceProtocolICMP TraceProtocol = "icmp"
|
||||
TraceProtocolTCP TraceProtocol = "tcp"
|
||||
TraceProtocolUDP TraceProtocol = "udp"
|
||||
)
|
||||
|
||||
type MeshMode string
|
||||
|
||||
const (
|
||||
MeshModeAllToAll MeshMode = "all_to_all"
|
||||
MeshModeOneToAll MeshMode = "one_to_all"
|
||||
MeshModePairs MeshMode = "pairs"
|
||||
)
|
||||
|
||||
type NodeEndpoint struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Family IPFamily `json:"ip_family"`
|
||||
}
|
||||
|
||||
type NextTraceParams struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetHost string `json:"target_host"`
|
||||
IPFamily IPFamily `json:"ip_family"`
|
||||
Protocol TraceProtocol `json:"protocol"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
RawOutput bool `json:"raw_output"`
|
||||
}
|
||||
|
||||
type NextTraceResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetHost string `json:"target_host"`
|
||||
IPFamily IPFamily `json:"ip_family"`
|
||||
Protocol TraceProtocol `json:"protocol"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Summary TraceSummary `json:"summary"`
|
||||
Hops []TraceHop `json:"hops"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
RawText string `json:"raw_text"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type TraceSummary struct {
|
||||
HopCount int `json:"hop_count"`
|
||||
Reached bool `json:"reached"`
|
||||
PacketLoss float64 `json:"packet_loss"`
|
||||
RTTMs float64 `json:"rtt_ms"`
|
||||
}
|
||||
|
||||
type TraceHop struct {
|
||||
Hop int `json:"hop"`
|
||||
Host string `json:"host"`
|
||||
IP string `json:"ip"`
|
||||
RTTMs float64 `json:"rtt_ms"`
|
||||
Loss float64 `json:"loss"`
|
||||
ASN string `json:"asn"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
type MeshTraceParams struct {
|
||||
SourceNodeIDs []string `json:"source_node_ids"`
|
||||
TargetNodeIDs []string `json:"target_node_ids"`
|
||||
Mode MeshMode `json:"mode"`
|
||||
IPFamily IPFamily `json:"ip_family"`
|
||||
Protocol TraceProtocol `json:"protocol"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
PerAgentLimit int `json:"per_agent_limit"`
|
||||
}
|
||||
|
||||
type MeshTraceAccepted struct {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
TotalPairs int `json:"total_pairs"`
|
||||
AcceptedAt string `json:"accepted_at"`
|
||||
PollIntervalMs int `json:"poll_interval_ms"`
|
||||
}
|
||||
|
||||
type MeshTraceJobSnapshot struct {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Failed int `json:"failed"`
|
||||
Running int `json:"running"`
|
||||
Results []NextTraceResult `json:"results"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetworkTestMethodConstants(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
MethodNetworkTestNextTrace: "networkTest.nextTrace",
|
||||
MethodNetworkTestIperf3: "networkTest.iperf3",
|
||||
MethodNetworkTestMeshTrace: "networkTest.meshTrace",
|
||||
MethodNetworkTestGetMeshTraceJob: "networkTest.getMeshTraceJob",
|
||||
}
|
||||
|
||||
for got, want := range tests {
|
||||
if got != want {
|
||||
t.Fatalf("method constant = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTraceParamsJSON(t *testing.T) {
|
||||
params := NextTraceParams{
|
||||
TaskID: "trace-1",
|
||||
SourceID: "agent-a",
|
||||
TargetID: "agent-b",
|
||||
TargetHost: "203.0.113.10",
|
||||
IPFamily: IPFamilyIPv4,
|
||||
Protocol: TraceProtocolICMP,
|
||||
MaxHops: 30,
|
||||
TimeoutMs: 5000,
|
||||
RawOutput: true,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertJSONKeys(t, data, []string{
|
||||
"task_id",
|
||||
"source_id",
|
||||
"target_id",
|
||||
"target_host",
|
||||
"ip_family",
|
||||
"protocol",
|
||||
"max_hops",
|
||||
"timeout_ms",
|
||||
"raw_output",
|
||||
})
|
||||
assertNoCamelCaseKeys(t, data)
|
||||
|
||||
var decoded NextTraceParams
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, params) {
|
||||
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTraceResultJSON(t *testing.T) {
|
||||
result := NextTraceResult{
|
||||
TaskID: "trace-1",
|
||||
SourceID: "agent-a",
|
||||
TargetID: "agent-b",
|
||||
TargetHost: "203.0.113.10",
|
||||
IPFamily: IPFamilyIPv4,
|
||||
Protocol: TraceProtocolICMP,
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
FinishedAt: "2026-06-05T10:00:01Z",
|
||||
DurationMs: 1000,
|
||||
OK: true,
|
||||
Error: "",
|
||||
Summary: TraceSummary{
|
||||
HopCount: 2,
|
||||
Reached: true,
|
||||
PacketLoss: 0,
|
||||
RTTMs: 12.5,
|
||||
},
|
||||
Hops: []TraceHop{
|
||||
{Hop: 1, Host: "router", IP: "192.0.2.1", RTTMs: 1.2, Loss: 0, ASN: "AS64500", Location: "lab"},
|
||||
{Hop: 2, Host: "target", IP: "203.0.113.10", RTTMs: 12.5, Loss: 0, ASN: "AS64501", Location: "edge"},
|
||||
},
|
||||
Raw: json.RawMessage(`{"parser":"nexttrace"}`),
|
||||
RawText: "raw trace text",
|
||||
ExitCode: 0,
|
||||
Truncated: false,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertJSONKeys(t, data, []string{
|
||||
"task_id",
|
||||
"source_id",
|
||||
"target_id",
|
||||
"target_host",
|
||||
"ip_family",
|
||||
"protocol",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"duration_ms",
|
||||
"ok",
|
||||
"error",
|
||||
"summary",
|
||||
"hops",
|
||||
"raw",
|
||||
"raw_text",
|
||||
"exit_code",
|
||||
"truncated",
|
||||
})
|
||||
assertJSONKeysAt(t, data, "summary", []string{"hop_count", "reached", "packet_loss", "rtt_ms"})
|
||||
assertJSONKeysAt(t, data, "hops.0", []string{"hop", "host", "ip", "rtt_ms", "loss", "asn", "location"})
|
||||
assertNoCamelCaseKeys(t, data)
|
||||
|
||||
var decoded NextTraceResult
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, result) {
|
||||
t.Fatalf("decoded result = %#v, want %#v", decoded, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshTraceParamsJSON(t *testing.T) {
|
||||
params := MeshTraceParams{
|
||||
SourceNodeIDs: []string{"agent-a"},
|
||||
TargetNodeIDs: []string{"agent-b", "agent-c"},
|
||||
Mode: MeshModeOneToAll,
|
||||
IPFamily: IPFamilyIPv6,
|
||||
Protocol: TraceProtocolTCP,
|
||||
MaxHops: 20,
|
||||
TimeoutMs: 8000,
|
||||
MaxConcurrency: 4,
|
||||
PerAgentLimit: 2,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertJSONKeys(t, data, []string{
|
||||
"source_node_ids",
|
||||
"target_node_ids",
|
||||
"mode",
|
||||
"ip_family",
|
||||
"protocol",
|
||||
"max_hops",
|
||||
"timeout_ms",
|
||||
"max_concurrency",
|
||||
"per_agent_limit",
|
||||
})
|
||||
assertNoCamelCaseKeys(t, data)
|
||||
|
||||
var decoded MeshTraceParams
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, params) {
|
||||
t.Fatalf("decoded params = %#v, want %#v", decoded, params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeshTraceJobSnapshotJSON(t *testing.T) {
|
||||
snapshot := MeshTraceJobSnapshot{
|
||||
JobID: "mesh-1",
|
||||
Status: "running",
|
||||
Total: 2,
|
||||
Done: 1,
|
||||
Failed: 0,
|
||||
Running: 1,
|
||||
Results: []NextTraceResult{
|
||||
{
|
||||
TaskID: "trace-1",
|
||||
SourceID: "agent-a",
|
||||
TargetID: "agent-b",
|
||||
TargetHost: "203.0.113.10",
|
||||
IPFamily: IPFamilyIPv4,
|
||||
Protocol: TraceProtocolUDP,
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
FinishedAt: "2026-06-05T10:00:01Z",
|
||||
DurationMs: 1000,
|
||||
OK: false,
|
||||
Error: "timeout",
|
||||
Summary: TraceSummary{HopCount: 0, Reached: false, PacketLoss: 1, RTTMs: 0},
|
||||
Hops: []TraceHop{},
|
||||
Raw: json.RawMessage(`null`),
|
||||
RawText: "",
|
||||
ExitCode: 1,
|
||||
Truncated: false,
|
||||
},
|
||||
},
|
||||
StartedAt: "2026-06-05T10:00:00Z",
|
||||
UpdatedAt: "2026-06-05T10:00:01Z",
|
||||
FinishedAt: "",
|
||||
Error: "",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertJSONKeys(t, data, []string{
|
||||
"job_id",
|
||||
"status",
|
||||
"total",
|
||||
"done",
|
||||
"failed",
|
||||
"running",
|
||||
"results",
|
||||
"started_at",
|
||||
"updated_at",
|
||||
"finished_at",
|
||||
"error",
|
||||
})
|
||||
assertJSONKeysAt(t, data, "results.0", []string{"task_id", "source_id", "target_id", "target_host"})
|
||||
assertNoCamelCaseKeys(t, data)
|
||||
|
||||
var decoded MeshTraceJobSnapshot
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, snapshot) {
|
||||
t.Fatalf("decoded snapshot = %#v, want %#v", decoded, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONKeys(t *testing.T, data []byte, keys []string) {
|
||||
t.Helper()
|
||||
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(data, &object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if _, ok := object[key]; !ok {
|
||||
t.Fatalf("missing JSON key %q in %s", key, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONKeysAt(t *testing.T, data []byte, path string, keys []string) {
|
||||
t.Helper()
|
||||
|
||||
var root any
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
object := jsonObjectAt(t, root, path)
|
||||
for _, key := range keys {
|
||||
if _, ok := object[key]; !ok {
|
||||
t.Fatalf("missing JSON key %q at %q in %s", key, path, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCamelCaseKeys(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
for key, next := range typed {
|
||||
if strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
t.Fatalf("JSON key %q contains uppercase characters in %s", key, data)
|
||||
}
|
||||
walk(next)
|
||||
}
|
||||
case []any:
|
||||
for _, next := range typed {
|
||||
walk(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
func jsonObjectAt(t *testing.T, root any, path string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
current := root
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
current = typed[part]
|
||||
case []any:
|
||||
if part != "0" {
|
||||
t.Fatalf("unsupported array path segment %q", part)
|
||||
}
|
||||
current = typed[0]
|
||||
default:
|
||||
t.Fatalf("path %q reached non-container value %#v", path, current)
|
||||
}
|
||||
}
|
||||
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("path %q is not a JSON object: %#v", path, current)
|
||||
}
|
||||
return object
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/secureconfig"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,9 +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/javascript"
|
||||
_ "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"
|
||||
)
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
package serverchan3
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
"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"`
|
||||
// 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{}
|
||||
})
|
||||
}
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChan3Sender{}
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
package serverchanturbo
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
"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,测试号用 , 分隔;企业微信应用用 | 分隔"`
|
||||
// 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{}
|
||||
})
|
||||
}
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChanTurboSender{}
|
||||
})
|
||||
}
|
||||
+52
-63
@@ -5,91 +5,80 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/renewal"
|
||||
)
|
||||
|
||||
func CheckExpireScheduledWork() {
|
||||
for {
|
||||
now := time.Now()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) // UTC 9AM = CST 17PM
|
||||
if now.After(next) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
duration := next.Sub(now)
|
||||
time.Sleep(duration)
|
||||
CheckExpire()
|
||||
}
|
||||
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.ExpireNotificationEnabledKey: false,
|
||||
config.ExpireNotificationLeadDaysKey: 7,
|
||||
})
|
||||
if err != nil {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
func CheckExpire() {
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.ExpireNotificationEnabledKey: false,
|
||||
config.ExpireNotificationLeadDaysKey: 7,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
clients_all, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
checkTime := time.Now()
|
||||
|
||||
// 过期提醒检查(仅当启用过期通知时)
|
||||
if cfg[config.ExpireNotificationEnabledKey].(bool) {
|
||||
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
|
||||
|
||||
type clientToExpireInfo struct {
|
||||
Name string
|
||||
DaysLeft int
|
||||
}
|
||||
|
||||
clients_all, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
var clientLeadToExpire []clientToExpireInfo
|
||||
|
||||
checkTime := time.Now()
|
||||
for _, client := range clients_all {
|
||||
clientExpireTime := client.ExpiredAt.ToTime()
|
||||
|
||||
// 过期提醒检查(仅当启用过期通知时)
|
||||
if cfg[config.ExpireNotificationEnabledKey].(bool) {
|
||||
notificationLeadDays := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
|
||||
|
||||
type clientToExpireInfo struct {
|
||||
Name string
|
||||
DaysLeft int
|
||||
if clientExpireTime.Before(checkTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
var clientLeadToExpire []clientToExpireInfo
|
||||
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
|
||||
|
||||
for _, client := range clients_all {
|
||||
clientExpireTime := client.ExpiredAt.ToTime()
|
||||
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
|
||||
remainingDuration := clientExpireTime.Sub(checkTime)
|
||||
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
|
||||
|
||||
if clientExpireTime.Before(checkTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
notificationThreshold := checkTime.Add(time.Duration(notificationLeadDays) * 24 * time.Hour)
|
||||
|
||||
if clientExpireTime.Before(notificationThreshold) || clientExpireTime.Equal(notificationThreshold) {
|
||||
remainingDuration := clientExpireTime.Sub(checkTime)
|
||||
daysLeft := int(math.Ceil(remainingDuration.Hours() / 24))
|
||||
|
||||
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
|
||||
Name: client.Name,
|
||||
DaysLeft: daysLeft,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(clientLeadToExpire) > 0 {
|
||||
message := ""
|
||||
for _, clientInfo := range clientLeadToExpire {
|
||||
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Expire,
|
||||
Time: time.Now(),
|
||||
Message: message,
|
||||
Emoji: "⏳",
|
||||
clientLeadToExpire = append(clientLeadToExpire, clientToExpireInfo{
|
||||
Name: client.Name,
|
||||
DaysLeft: daysLeft,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 等待1秒,防止多次触发
|
||||
time.Sleep(time.Second)
|
||||
for _, client := range clients_all {
|
||||
renewal.CheckAndAutoRenewal(client)
|
||||
if len(clientLeadToExpire) > 0 {
|
||||
message := ""
|
||||
for _, clientInfo := range clientLeadToExpire {
|
||||
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Expire,
|
||||
Time: time.Now(),
|
||||
Message: message,
|
||||
Emoji: "⏳",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range clients_all {
|
||||
renewal.CheckAndAutoRenewal(client)
|
||||
}
|
||||
}
|
||||
+14
-26
@@ -1,6 +1,7 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
@@ -11,21 +12,18 @@ import (
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// LoadNotificationService 管理定时器和任务
|
||||
type LoadNotificationService struct {
|
||||
mu sync.Mutex
|
||||
tickers map[int]*time.Ticker
|
||||
tasks map[int][]models.LoadNotification
|
||||
stopChan chan struct{}
|
||||
mu sync.Mutex
|
||||
tasks map[int][]models.LoadNotification
|
||||
}
|
||||
|
||||
var LoadNotificationManager = &LoadNotificationService{
|
||||
tickers: make(map[int]*time.Ticker),
|
||||
tasks: make(map[int][]models.LoadNotification),
|
||||
stopChan: make(chan struct{}),
|
||||
tasks: make(map[int][]models.LoadNotification),
|
||||
}
|
||||
|
||||
// Reload 重载时间表
|
||||
@@ -33,11 +31,7 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 停止所有现有定时器
|
||||
for _, ticker := range m.tickers {
|
||||
ticker.Stop()
|
||||
}
|
||||
m.tickers = make(map[int]*time.Ticker)
|
||||
corn.RemovePrefix("load-notification:")
|
||||
m.tasks = make(map[int][]models.LoadNotification)
|
||||
|
||||
// 按Interval分组任务
|
||||
@@ -48,22 +42,16 @@ func (m *LoadNotificationService) Reload(loadNotifications []models.LoadNotifica
|
||||
|
||||
// 为每个唯一的Interval创建定时器
|
||||
for interval, tasks := range taskGroups {
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
|
||||
m.tickers[interval] = ticker
|
||||
interval := interval
|
||||
tasks := append([]models.LoadNotification(nil), tasks...)
|
||||
m.tasks[interval] = tasks
|
||||
|
||||
go func(ticker *time.Ticker, tasks []models.LoadNotification) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for _, task := range tasks {
|
||||
go executeLoadNotificationTask(task)
|
||||
}
|
||||
case <-m.stopChan:
|
||||
return
|
||||
}
|
||||
if err := corn.AddFunc(fmt.Sprintf("load-notification:%d", interval), corn.Every(time.Duration(interval)*time.Minute), func() {
|
||||
for _, task := range tasks {
|
||||
go executeLoadNotificationTask(task)
|
||||
}
|
||||
}(ticker, tasks)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/renewal"
|
||||
)
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小
|
||||
// 由外部协程每分钟调用一次
|
||||
func CheckTraffic() {
|
||||
// 获取最新上报与客户端配置
|
||||
reports := ws.GetLatestReport()
|
||||
reports := agent_runtime.GetLatestReport()
|
||||
if len(reports) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func CheckTraffic() {
|
||||
|
||||
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{
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InitTrafficReportSchedule 注册三个定时任务:日报、周报、月报
|
||||
func InitTrafficReportSchedule() {
|
||||
// 日报:每天凌晨 0 点
|
||||
if err := corn.AddFunc("traffic-report-daily", "0 0 0 * * *", func() {
|
||||
sendTrafficReport(true, false, false)
|
||||
}); err != nil {
|
||||
log.Println("Failed to register daily traffic report job:", err)
|
||||
}
|
||||
|
||||
// 周报:每周一凌晨 0 点 (dow=1)
|
||||
if err := corn.AddFunc("traffic-report-weekly", "0 0 0 * * 1", func() {
|
||||
sendTrafficReport(false, true, false)
|
||||
}); err != nil {
|
||||
log.Println("Failed to register weekly traffic report job:", err)
|
||||
}
|
||||
|
||||
// 月报:每月 1 日凌晨 0 点
|
||||
if err := corn.AddFunc("traffic-report-monthly", "0 0 0 1 * *", func() {
|
||||
sendTrafficReport(false, false, true)
|
||||
}); err != nil {
|
||||
log.Println("Failed to register monthly traffic report job:", err)
|
||||
}
|
||||
|
||||
log.Println("Traffic report schedules registered: daily, weekly, monthly")
|
||||
}
|
||||
|
||||
// sendTrafficReport 汇聚所有启用了指定报告类型的服务器流量,合并成一条通知发送
|
||||
func sendTrafficReport(daily, weekly, monthly bool) {
|
||||
// 检查全局通知开关
|
||||
enabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
|
||||
if err != nil || !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
now := time.Now()
|
||||
|
||||
// 计算时间范围
|
||||
var start, end time.Time
|
||||
var eventType, label, suffix string
|
||||
|
||||
switch {
|
||||
case daily:
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
start = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
|
||||
end = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, yesterday.Location())
|
||||
eventType = messageevent.DReport
|
||||
label = "daily"
|
||||
suffix = "昨日流量"
|
||||
case weekly:
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
lastMonday := now.AddDate(0, 0, -(weekday-1)-7)
|
||||
lastSunday := lastMonday.AddDate(0, 0, 6)
|
||||
start = time.Date(lastMonday.Year(), lastMonday.Month(), lastMonday.Day(), 0, 0, 0, 0, lastMonday.Location())
|
||||
end = time.Date(lastSunday.Year(), lastSunday.Month(), lastSunday.Day(), 23, 59, 59, 0, lastSunday.Location())
|
||||
eventType = messageevent.WReport
|
||||
label = "weekly"
|
||||
suffix = "上周流量"
|
||||
case monthly:
|
||||
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
|
||||
lastDayOfLastMonth := firstOfThisMonth.Add(-time.Second)
|
||||
start = firstOfLastMonth
|
||||
end = lastDayOfLastMonth
|
||||
eventType = messageevent.MReport
|
||||
label = "monthly"
|
||||
suffix = "上个月流量"
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// 查询所有启用该类型报告的服务器配置
|
||||
var notifications []models.TrafficReportNotification
|
||||
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
|
||||
if daily {
|
||||
query = query.Where("daily = ?", true)
|
||||
} else if weekly {
|
||||
query = query.Where("weekly = ?", true)
|
||||
} else if monthly {
|
||||
query = query.Where("monthly = ?", true)
|
||||
}
|
||||
if err := query.Find(¬ifications).Error; err != nil {
|
||||
log.Printf("Failed to query traffic report notifications (%s): %v", label, err)
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取客户端信息
|
||||
clientUUIDs := make([]string, 0, len(notifications))
|
||||
for _, n := range notifications {
|
||||
clientUUIDs = append(clientUUIDs, n.Client)
|
||||
}
|
||||
var clientList []models.Client
|
||||
if err := db.Where("uuid IN ?", clientUUIDs).Find(&clientList).Error; err != nil {
|
||||
log.Printf("Failed to query clients for traffic report (%s): %v", label, err)
|
||||
return
|
||||
}
|
||||
clientMap := make(map[string]models.Client, len(clientList))
|
||||
for _, c := range clientList {
|
||||
clientMap[c.UUID] = c
|
||||
}
|
||||
|
||||
// 为每个服务器统计流量并拼接消息
|
||||
var lines []string
|
||||
eventClients := make([]models.Client, 0, len(notifications))
|
||||
for _, n := range notifications {
|
||||
c, ok := clientMap[n.Client]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
used, err := getClientTrafficInRange(n.Client, c.TrafficLimitType, start, end)
|
||||
if err != nil {
|
||||
log.Printf("Failed to compute traffic for client %s (%s): %v", n.Client, label, err)
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf("%s%s:%s", c.Name, suffix, humanBytes(used)))
|
||||
eventClients = append(eventClients, c)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.Join(lines, "\n")
|
||||
var emoji string
|
||||
switch {
|
||||
case daily:
|
||||
emoji = "📊"
|
||||
case weekly:
|
||||
emoji = "📈"
|
||||
case monthly:
|
||||
emoji = "📅"
|
||||
}
|
||||
|
||||
if err := messageSender.SendEvent(models.EventMessage{
|
||||
Event: eventType,
|
||||
Clients: eventClients,
|
||||
Time: now,
|
||||
Emoji: emoji,
|
||||
Message: message,
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send %s traffic report: %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getClientTrafficInRange 查询某客户端在指定时间段内的流量增量
|
||||
// 通过累加持久化的精确流量增量字段计算用量
|
||||
func getClientTrafficInRange(clientUUID string, trafficType string, start, end time.Time) (int64, error) {
|
||||
return getClientTrafficInRangeWithDB(dbcore.GetDBInstance(), clientUUID, trafficType, start, end)
|
||||
}
|
||||
|
||||
func getClientTrafficInRangeWithDB(db *gorm.DB, clientUUID string, trafficType string, start, end time.Time) (int64, error) {
|
||||
type trafficDeltaRecord struct {
|
||||
Time models.LocalTime `gorm:"column:time"`
|
||||
TrafficUp int64 `gorm:"column:traffic_up"`
|
||||
TrafficDown int64 `gorm:"column:traffic_down"`
|
||||
}
|
||||
|
||||
var recentRecords []trafficDeltaRecord
|
||||
if err := db.Table("records").
|
||||
Select("time, traffic_up, traffic_down").
|
||||
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
|
||||
Find(&recentRecords).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var longTermRecords []trafficDeltaRecord
|
||||
if err := db.Table("records_long_term").
|
||||
Select("time, traffic_up, traffic_down").
|
||||
Where("client = ? AND time >= ? AND time <= ?", clientUUID, start, end).
|
||||
Find(&longTermRecords).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var totalUp int64
|
||||
var totalDown int64
|
||||
longTermSlots := make(map[time.Time]struct{}, len(longTermRecords))
|
||||
|
||||
for _, record := range longTermRecords {
|
||||
totalUp += record.TrafficUp
|
||||
totalDown += record.TrafficDown
|
||||
longTermSlots[record.Time.ToTime()] = struct{}{}
|
||||
}
|
||||
|
||||
for _, record := range recentRecords {
|
||||
// records_long_term stores one aggregated row per 15-minute slot.
|
||||
// Skip raw rows only when that exact slot is already present there.
|
||||
slot := record.Time.ToTime().Truncate(15 * time.Minute)
|
||||
if _, exists := longTermSlots[slot]; exists {
|
||||
continue
|
||||
}
|
||||
totalUp += record.TrafficUp
|
||||
totalDown += record.TrafficDown
|
||||
}
|
||||
|
||||
return computeUsedByType(strings.ToLower(trafficType), totalUp, totalDown), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGetClientTrafficInRangeAvoidsOverlappingRawAndLongTermRows(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
clientUUID := "client-overlap"
|
||||
start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
sharedSlot := start.Add(15 * time.Minute)
|
||||
|
||||
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(sharedSlot),
|
||||
TrafficUp: 100,
|
||||
TrafficDown: 200,
|
||||
}).Error)
|
||||
|
||||
rawRecords := []models.Record{
|
||||
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(1 * time.Minute)), TrafficUp: 40, TrafficDown: 80},
|
||||
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(5 * time.Minute)), TrafficUp: 60, TrafficDown: 120},
|
||||
{Client: clientUUID, Time: models.FromTime(sharedSlot.Add(16 * time.Minute)), TrafficUp: 30, TrafficDown: 50},
|
||||
}
|
||||
for _, record := range rawRecords {
|
||||
assert.NoError(t, db.Create(&record).Error)
|
||||
}
|
||||
|
||||
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, sharedSlot.Add(30*time.Minute))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(380), used)
|
||||
}
|
||||
|
||||
func TestGetClientTrafficInRangeSumsPersistedDeltasAcrossCounterReset(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Record{}))
|
||||
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
|
||||
|
||||
clientUUID := "client-reset"
|
||||
start := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
|
||||
records := []models.Record{
|
||||
{Client: clientUUID, Time: models.FromTime(start.Add(0 * time.Minute)), NetTotalUp: 100, NetTotalDown: 200, TrafficUp: 0, TrafficDown: 0},
|
||||
{Client: clientUUID, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 150, NetTotalDown: 260, TrafficUp: 50, TrafficDown: 60},
|
||||
{Client: clientUUID, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 30, TrafficUp: 10, TrafficDown: 30},
|
||||
{Client: clientUUID, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 40, TrafficUp: 15, TrafficDown: 10},
|
||||
}
|
||||
for _, record := range records {
|
||||
assert.NoError(t, db.Create(&record).Error)
|
||||
}
|
||||
|
||||
used, err := getClientTrafficInRangeWithDB(db, clientUUID, "sum", start, start.Add(20*time.Minute))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(175), used)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/cloudflare"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/generic"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/github"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/qq"
|
||||
)
|
||||
|
||||
func All() {
|
||||
//empty function to ensure all OIDC providers are registered
|
||||
}
|
||||
+20
-46
@@ -2,18 +2,20 @@ package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/komari-monitor/komari/pkg/corn"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
)
|
||||
|
||||
// PingTaskManager 管理定时器和任务
|
||||
type PingTaskManager struct {
|
||||
mu sync.Mutex
|
||||
cancelFunc context.CancelFunc
|
||||
tasks map[int][]models.PingTask
|
||||
mu sync.Mutex
|
||||
tasks map[int][]models.PingTask
|
||||
}
|
||||
|
||||
var manager = &PingTaskManager{
|
||||
@@ -25,12 +27,7 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cancelFunc != nil {
|
||||
m.cancelFunc()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancelFunc = cancel
|
||||
|
||||
corn.RemovePrefix("ping:")
|
||||
m.tasks = make(map[int][]models.PingTask)
|
||||
|
||||
// 按Interval分组任务
|
||||
@@ -44,40 +41,22 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
|
||||
|
||||
// 为每个唯一的Interval创建协程
|
||||
for interval, tasks := range taskGroups {
|
||||
interval := interval
|
||||
tasks := append([]models.PingTask(nil), tasks...)
|
||||
m.tasks[interval] = tasks
|
||||
go m.runPreciseLoop(ctx, time.Duration(interval)*time.Second, tasks)
|
||||
if err := corn.AddContextFunc(fmt.Sprintf("ping:%d", interval), corn.Every(time.Duration(interval)*time.Second), false, func(ctx context.Context) {
|
||||
for _, task := range tasks {
|
||||
go executePingTask(ctx, task)
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PingTaskManager) runPreciseLoop(ctx context.Context, interval time.Duration, tasks []models.PingTask) {
|
||||
// Start the first timer.
|
||||
timer := time.NewTimer(interval)
|
||||
|
||||
// This will be the reference point for all future ticks.
|
||||
// By adding the interval to this time, we avoid accumulating execution delays.
|
||||
nextTick := time.Now().Add(interval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
onlineClients := ws.GetConnectedClients()
|
||||
for _, task := range tasks {
|
||||
go executePingTask(ctx, task, onlineClients)
|
||||
}
|
||||
|
||||
nextTick = nextTick.Add(interval)
|
||||
timer.Reset(time.Until(nextTick))
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePingTask 执行单个PingTask
|
||||
func executePingTask(ctx context.Context, task models.PingTask, onlineClients map[string]*ws.SafeConn) {
|
||||
func executePingTask(ctx context.Context, task models.PingTask) {
|
||||
var message struct {
|
||||
TaskID uint `json:"ping_task_id"`
|
||||
Message string `json:"message"`
|
||||
@@ -90,7 +69,7 @@ func executePingTask(ctx context.Context, task models.PingTask, onlineClients ma
|
||||
message.Type = task.Type
|
||||
message.Target = task.Target
|
||||
|
||||
for _, clientUUID := range targetPingClientUUIDs(task, onlineClients) {
|
||||
for _, clientUUID := range targetPingClientUUIDs(task) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context was canceled, stop sending pings.
|
||||
@@ -99,17 +78,12 @@ func executePingTask(ctx context.Context, task models.PingTask, onlineClients ma
|
||||
// Context is still active, continue.
|
||||
}
|
||||
|
||||
if conn, exists := onlineClients[clientUUID]; exists && conn != nil {
|
||||
if err := conn.WriteJSON(message); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
agent_runtime.DispatchPing(clientUUID, message, v2.PingParams{TaskID: task.Id, Type: task.Type, Target: task.Target})
|
||||
}
|
||||
}
|
||||
|
||||
// targetPingClientUUIDs 根据任务配置计算本次调度需要下发的在线服务器列表。
|
||||
func targetPingClientUUIDs(task models.PingTask, onlineClients map[string]*ws.SafeConn) []string {
|
||||
_ = onlineClients
|
||||
func targetPingClientUUIDs(task models.PingTask) []string {
|
||||
return task.Clients
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
)
|
||||
|
||||
func CheckAndAutoRenewal(client models.Client) {
|
||||
@@ -24,7 +24,7 @@ func CheckAndAutoRenewal(client models.Client) {
|
||||
return
|
||||
}
|
||||
// 不在线则不续费
|
||||
if _, ok := ws.GetConnectedClients()[client.UUID]; !ok {
|
||||
if _, ok := agent_runtime.GetConnectedClients()[client.UUID]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+20
-20
@@ -5,14 +5,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// AverageReport 根据 topPercentage 参数计算报告的平均值。
|
||||
// 如果 topPercentage 为 0,则计算所有记录的平均值。
|
||||
// 如果 topPercentage 大于 0 且小于等于 1,则计算从大到小排序后的前 topPercentage 记录的平均值。
|
||||
func AverageReport(uuid string, time time.Time, records []common.Report, topPercentage float64) models.Record {
|
||||
func AverageReport(uuid string, time time.Time, records []v1.Report, topPercentage float64) models.Record {
|
||||
count := len(records)
|
||||
if count == 0 {
|
||||
return models.Record{}
|
||||
@@ -27,7 +27,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
}
|
||||
|
||||
// 定义一个辅助函数,用于排序和求和
|
||||
sumAndSort := func(getFloat32Value func(common.Report) float32, getInt64Value func(common.Report) int64, isFloat bool) (float32, int64) {
|
||||
sumAndSort := func(getFloat32Value func(v1.Report) float32, getInt64Value func(v1.Report) int64, isFloat bool) (float32, int64) {
|
||||
if isFloat {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return getFloat32Value(records[i]) > getFloat32Value(records[j])
|
||||
@@ -54,31 +54,31 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
var sumPROCESS, sumConnections, sumConnectionsUDP int
|
||||
|
||||
if topPercentage > 0 && topPercentage <= 1 {
|
||||
sumCPU, _ = sumAndSort(func(r common.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
|
||||
sumLOAD, _ = sumAndSort(func(r common.Report) float32 { return float32(r.Load.Load1) }, nil, true)
|
||||
sumGPU, _ = sumAndSort(func(r common.Report) float32 {
|
||||
sumCPU, _ = sumAndSort(func(r v1.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
|
||||
sumLOAD, _ = sumAndSort(func(r v1.Report) float32 { return float32(r.Load.Load1) }, nil, true)
|
||||
sumGPU, _ = sumAndSort(func(r v1.Report) float32 {
|
||||
if r.GPU != nil {
|
||||
return float32(r.GPU.AverageUsage)
|
||||
}
|
||||
return 0
|
||||
}, nil, true)
|
||||
|
||||
_, sumRAM = sumAndSort(nil, func(r common.Report) int64 { return r.Ram.Used }, false)
|
||||
//_, sumRAMTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Ram.Total }, false)
|
||||
_, sumSWAP = sumAndSort(nil, func(r common.Report) int64 { return r.Swap.Used }, false)
|
||||
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Swap.Total }, false)
|
||||
_, sumDISK = sumAndSort(nil, func(r common.Report) int64 { return r.Disk.Used }, false)
|
||||
//_, sumDISKTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Disk.Total }, false)
|
||||
_, sumNETIn = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Down }, false)
|
||||
_, sumNETOut = sumAndSort(nil, func(r common.Report) int64 { return r.Network.Up }, false)
|
||||
_, sumNETTotalUp = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalUp }, false)
|
||||
_, sumNETTotalDown = sumAndSort(nil, func(r common.Report) int64 { return r.Network.TotalDown }, false)
|
||||
_, sumRAM = sumAndSort(nil, func(r v1.Report) int64 { return r.Ram.Used }, false)
|
||||
//_, sumRAMTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Ram.Total }, false)
|
||||
_, sumSWAP = sumAndSort(nil, func(r v1.Report) int64 { return r.Swap.Used }, false)
|
||||
//_, sumSWAPTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Swap.Total }, false)
|
||||
_, sumDISK = sumAndSort(nil, func(r v1.Report) int64 { return r.Disk.Used }, false)
|
||||
//_, sumDISKTotal = sumAndSort(nil, nil, func(r v1.Report) int64 { return r.Disk.Total }, false)
|
||||
_, sumNETIn = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.Down }, false)
|
||||
_, sumNETOut = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.Up }, false)
|
||||
_, sumNETTotalUp = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.TotalUp }, false)
|
||||
_, sumNETTotalDown = sumAndSort(nil, func(r v1.Report) int64 { return r.Network.TotalDown }, false)
|
||||
|
||||
_, sumInt := sumAndSort(nil, func(r common.Report) int64 { return int64(r.Process) }, false)
|
||||
_, sumInt := sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Process) }, false)
|
||||
sumPROCESS = int(sumInt)
|
||||
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.TCP) }, false)
|
||||
_, sumInt = sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Connections.TCP) }, false)
|
||||
sumConnections = int(sumInt)
|
||||
_, sumInt = sumAndSort(nil, func(r common.Report) int64 { return int64(r.Connections.UDP) }, false)
|
||||
_, sumInt = sumAndSort(nil, func(r v1.Report) int64 { return int64(r.Connections.UDP) }, false)
|
||||
sumConnectionsUDP = int(sumInt)
|
||||
|
||||
} else { // 计算所有记录的平均值
|
||||
@@ -131,7 +131,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
|
||||
|
||||
// AverageGPUReports 使用与 AverageReport 相同的聚合逻辑处理GPU数据
|
||||
// 返回每个GPU设备的聚合记录
|
||||
func AverageGPUReports(uuid string, time time.Time, reports []common.Report, topPercentage float64) []models.GPURecord {
|
||||
func AverageGPUReports(uuid string, time time.Time, reports []v1.Report, topPercentage float64) []models.GPURecord {
|
||||
if len(reports) == 0 {
|
||||
return []models.GPURecord{}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package ws
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
)
|
||||
|
||||
var (
|
||||
connectedClients = make(map[string]*SafeConn)
|
||||
ConnectedUsers = []*websocket.Conn{}
|
||||
latestReport = make(map[string]*common.Report)
|
||||
connectedClients = make(map[string]*connection.SafeConn)
|
||||
connectedClientV2 = make(map[string]bool)
|
||||
latestReport = make(map[string]*v1.Report)
|
||||
// presenceOnly stores online state for non-WebSocket agents (e.g., Nezha gRPC)
|
||||
// value keeps connectionID and a soft expiration to avoid flicker
|
||||
presenceOnly = make(map[string]struct {
|
||||
@@ -21,28 +21,42 @@ var (
|
||||
mu = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func GetConnectedClients() map[string]*SafeConn {
|
||||
func GetConnectedClients() map[string]*connection.SafeConn {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
clientsCopy := make(map[string]*SafeConn)
|
||||
clientsCopy := make(map[string]*connection.SafeConn)
|
||||
for k, v := range connectedClients {
|
||||
clientsCopy[k] = v
|
||||
}
|
||||
return clientsCopy
|
||||
}
|
||||
|
||||
func SetConnectedClients(uuid string, conn *SafeConn) {
|
||||
func SetConnectedClients(uuid string, conn *connection.SafeConn) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
connectedClients[uuid] = conn
|
||||
}
|
||||
func DeleteClientConditionally(uuid string, connToRemove *SafeConn) {
|
||||
|
||||
func SetClientProtocolVersion(uuid string, version int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
connectedClientV2[uuid] = version >= 2
|
||||
}
|
||||
|
||||
func IsV2Client(uuid string) bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return connectedClientV2[uuid]
|
||||
}
|
||||
|
||||
func DeleteClientConditionally(uuid string, connToRemove *connection.SafeConn) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// 检查当前 map 里的 conn 是否就是要删除的这一个
|
||||
if currentConn, exists := connectedClients[uuid]; exists && currentConn == connToRemove {
|
||||
delete(connectedClients, uuid)
|
||||
delete(connectedClientV2, uuid)
|
||||
}
|
||||
}
|
||||
func DeleteConnectedClients(uuid string) {
|
||||
@@ -50,6 +64,7 @@ func DeleteConnectedClients(uuid string) {
|
||||
defer mu.Unlock()
|
||||
// 只从 map 中删除,不再负责关闭连接
|
||||
delete(connectedClients, uuid)
|
||||
delete(connectedClientV2, uuid)
|
||||
}
|
||||
|
||||
// SetPresence sets or clears presence for non-WebSocket agents.
|
||||
@@ -102,16 +117,16 @@ func GetAllOnlineUUIDs() []string {
|
||||
}
|
||||
return res
|
||||
}
|
||||
func GetLatestReport() map[string]*common.Report {
|
||||
func GetLatestReport() map[string]*v1.Report {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
reportCopy := make(map[string]*common.Report)
|
||||
reportCopy := make(map[string]*v1.Report)
|
||||
for k, v := range latestReport {
|
||||
reportCopy[k] = v
|
||||
}
|
||||
return reportCopy
|
||||
}
|
||||
func SetLatestReport(uuid string, report *common.Report) {
|
||||
func SetLatestReport(uuid string, report *v1.Report) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
latestReport[uuid] = report
|
||||
@@ -0,0 +1,236 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
v2EventQueueLimit = 128
|
||||
v2EventTTL = 5 * time.Minute
|
||||
v2PingEventTTL = 3 * time.Second
|
||||
)
|
||||
|
||||
type v2EventQueue struct {
|
||||
events []v2.Event
|
||||
signal chan struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
v2EventMu sync.Mutex
|
||||
v2EventQueues = make(map[string]*v2EventQueue)
|
||||
)
|
||||
|
||||
func getV2EventQueueLocked(uuid string) *v2EventQueue {
|
||||
q := v2EventQueues[uuid]
|
||||
if q == nil {
|
||||
q = &v2EventQueue{signal: make(chan struct{})}
|
||||
v2EventQueues[uuid] = q
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func DispatchV2Event(uuid, method string, params any) bool {
|
||||
if conn := GetConnectedClients()[uuid]; conn != nil {
|
||||
payload := v2.Request{JSONRPC: v2.Version, Method: method, Params: params}
|
||||
if conn.WriteJSON(payload) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !IsV2Client(uuid) {
|
||||
return false
|
||||
}
|
||||
EnqueueV2Event(uuid, method, params)
|
||||
return true
|
||||
}
|
||||
|
||||
func DispatchPing(uuid string, legacy any, params v2.PingParams) bool {
|
||||
if conn := GetConnectedClients()[uuid]; conn != nil {
|
||||
payload := legacy
|
||||
if IsV2Client(uuid) {
|
||||
payload = v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentPing, Params: params}
|
||||
}
|
||||
if conn.WriteJSON(payload) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !IsV2Client(uuid) {
|
||||
return false
|
||||
}
|
||||
EnqueueV2Event(uuid, v2.MethodAgentPing, params)
|
||||
return true
|
||||
}
|
||||
|
||||
func IsAgentOnline(uuid string) bool {
|
||||
if GetConnectedClients()[uuid] != nil {
|
||||
return true
|
||||
}
|
||||
return IsV2Client(uuid)
|
||||
}
|
||||
|
||||
func EnqueueV2Event(uuid, method string, params any) v2.Event {
|
||||
now := time.Now()
|
||||
ttl := v2EventTTL
|
||||
if method == v2.MethodAgentPing {
|
||||
ttl = v2PingEventTTL
|
||||
}
|
||||
event := v2.Event{
|
||||
ID: newV2EventID(),
|
||||
Method: method,
|
||||
Params: params,
|
||||
CreatedAt: now.Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(ttl).Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
v2EventMu.Lock()
|
||||
q := getV2EventQueueLocked(uuid)
|
||||
pruneExpiredV2EventsLocked(q)
|
||||
coalesceV2EventLocked(q, event)
|
||||
q.events = append(q.events, event)
|
||||
if len(q.events) > v2EventQueueLimit {
|
||||
q.events = q.events[len(q.events)-v2EventQueueLimit:]
|
||||
}
|
||||
close(q.signal)
|
||||
q.signal = make(chan struct{})
|
||||
v2EventMu.Unlock()
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func newV2EventID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func coalesceV2EventLocked(q *v2EventQueue, event v2.Event) {
|
||||
key := v2EventCoalesceKey(event)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
filtered := q.events[:0]
|
||||
for _, existing := range q.events {
|
||||
if v2EventCoalesceKey(existing) != key {
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
}
|
||||
q.events = filtered
|
||||
}
|
||||
|
||||
func v2EventCoalesceKey(event v2.Event) string {
|
||||
if event.Method != v2.MethodAgentPing {
|
||||
return ""
|
||||
}
|
||||
var params v2.PingParams
|
||||
if err := bindV2EventParams(event.Params, ¶ms); err != nil || params.TaskID == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", event.Method, params.TaskID)
|
||||
}
|
||||
|
||||
func bindV2EventParams(raw any, target any) error {
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, target)
|
||||
}
|
||||
|
||||
func ackV2EventsLocked(q *v2EventQueue, ackIDs []string) {
|
||||
if len(ackIDs) == 0 || len(q.events) == 0 {
|
||||
return
|
||||
}
|
||||
acked := make(map[string]struct{}, len(ackIDs))
|
||||
for _, id := range ackIDs {
|
||||
acked[id] = struct{}{}
|
||||
}
|
||||
filtered := q.events[:0]
|
||||
for _, event := range q.events {
|
||||
if _, ok := acked[event.ID]; !ok {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
q.events = filtered
|
||||
}
|
||||
|
||||
func pruneExpiredV2EventsLocked(q *v2EventQueue) {
|
||||
if len(q.events) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
filtered := q.events[:0]
|
||||
for _, event := range q.events {
|
||||
if event.ExpiresAt == "" {
|
||||
filtered = append(filtered, event)
|
||||
continue
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, event.ExpiresAt)
|
||||
if err != nil || expiresAt.After(now) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
q.events = filtered
|
||||
}
|
||||
|
||||
func TakeV2Events(uuid string, ackIDs []string, limit int) []v2.Event {
|
||||
v2EventMu.Lock()
|
||||
defer v2EventMu.Unlock()
|
||||
|
||||
q := getV2EventQueueLocked(uuid)
|
||||
ackV2EventsLocked(q, ackIDs)
|
||||
pruneExpiredV2EventsLocked(q)
|
||||
return takeV2EventsLocked(q, limit)
|
||||
}
|
||||
|
||||
func AckV2Events(uuid string, ackIDs []string) {
|
||||
if len(ackIDs) == 0 {
|
||||
return
|
||||
}
|
||||
v2EventMu.Lock()
|
||||
defer v2EventMu.Unlock()
|
||||
|
||||
q := v2EventQueues[uuid]
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
ackV2EventsLocked(q, ackIDs)
|
||||
}
|
||||
|
||||
func takeV2EventsLocked(q *v2EventQueue, limit int) []v2.Event {
|
||||
if limit <= 0 || limit > len(q.events) {
|
||||
limit = len(q.events)
|
||||
}
|
||||
events := make([]v2.Event, limit)
|
||||
copy(events, q.events[:limit])
|
||||
return events
|
||||
}
|
||||
|
||||
func WaitV2Events(uuid string, ackIDs []string, timeout time.Duration) []v2.Event {
|
||||
v2EventMu.Lock()
|
||||
q := getV2EventQueueLocked(uuid)
|
||||
ackV2EventsLocked(q, ackIDs)
|
||||
pruneExpiredV2EventsLocked(q)
|
||||
events := takeV2EventsLocked(q, v2EventQueueLimit)
|
||||
if len(events) > 0 || timeout <= 0 {
|
||||
v2EventMu.Unlock()
|
||||
return events
|
||||
}
|
||||
signal := q.signal
|
||||
v2EventMu.Unlock()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-timer.C:
|
||||
}
|
||||
return TakeV2Events(uuid, nil, v2EventQueueLimit)
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
)
|
||||
|
||||
func RequireSensitive2FA() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := VerifySensitive2FA(c); err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, err.Error())
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func VerifySensitive2FA(c *gin.Context) error {
|
||||
if _, ok := c.Get("api_key"); ok {
|
||||
return nil
|
||||
}
|
||||
uuid, ok := c.Get("uuid")
|
||||
if !ok {
|
||||
return err2FARequired()
|
||||
}
|
||||
uuidString, ok := uuid.(string)
|
||||
if !ok || uuidString == "" {
|
||||
return err2FARequired()
|
||||
}
|
||||
user, err := accounts.GetUserByUUID(uuidString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.TwoFactor == "" {
|
||||
return nil
|
||||
}
|
||||
code := get2FACode(c)
|
||||
if code == "" {
|
||||
return err2FARequired()
|
||||
}
|
||||
valid, err := accounts.Verify2Fa(uuidString, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return err2FAInvalid()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get2FACode(c *gin.Context) string {
|
||||
if code, ok := c.Get("2fa_code"); ok {
|
||||
if codeString, ok := code.(string); ok && codeString != "" {
|
||||
return codeString
|
||||
}
|
||||
}
|
||||
if code := c.GetHeader("X-2FA-Code"); code != "" {
|
||||
return code
|
||||
}
|
||||
if code := c.GetHeader("X-Two-Factor-Code"); code != "" {
|
||||
return code
|
||||
}
|
||||
for _, key := range []string{"2fa_code", "two_factor_code", "otp"} {
|
||||
if code := c.Query(key); code != "" {
|
||||
return code
|
||||
}
|
||||
}
|
||||
if c.Request.Body == nil || c.Request.Method == http.MethodGet {
|
||||
return ""
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if len(bodyBytes) == 0 {
|
||||
return ""
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &body); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"2fa_code", "two_factor_code", "otp"} {
|
||||
if value, ok := body[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func err2FARequired() error {
|
||||
return &sensitive2FAError{"2FA code is required"}
|
||||
}
|
||||
|
||||
func err2FAInvalid() error {
|
||||
return &sensitive2FAError{"Invalid 2FA code"}
|
||||
}
|
||||
|
||||
type sensitive2FAError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *sensitive2FAError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Respond sends a standardized JSON response.
|
||||
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// RespondSuccess sends a success response with data.
|
||||
func RespondSuccess(c *gin.Context, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", "", data)
|
||||
}
|
||||
|
||||
// RespondSuccessMessage sends a success response with message and data.
|
||||
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
|
||||
Respond(c, http.StatusOK, "success", message, data)
|
||||
}
|
||||
|
||||
// RespondError sends an error response with message.
|
||||
func RespondError(c *gin.Context, httpStatus int, message string) {
|
||||
Respond(c, httpStatus, "error", message, nil)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/security"
|
||||
)
|
||||
|
||||
type WebSocketUpgradeOption func(*websocket.Upgrader)
|
||||
|
||||
func IsWebSocketUpgrade(c *gin.Context) bool {
|
||||
return websocket.IsWebSocketUpgrade(c.Request)
|
||||
}
|
||||
|
||||
func EnableWebSocketCompression(upgrader *websocket.Upgrader) {
|
||||
upgrader.EnableCompression = true
|
||||
}
|
||||
|
||||
func UpgradeWebSocket(c *gin.Context, options ...WebSocketUpgradeOption) (*websocket.Conn, error) {
|
||||
if !IsWebSocketUpgrade(c) {
|
||||
return nil, fmt.Errorf("require websocket upgrade")
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: CheckWebSocketOrigin,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(&upgrader)
|
||||
}
|
||||
return upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
}
|
||||
|
||||
func CheckWebSocketOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if strings.EqualFold(os.Getenv("KOMARI_WS_DISABLE_ORIGIN"), "true") {
|
||||
return true
|
||||
}
|
||||
if security.IsAPIKeyRequest(r) {
|
||||
return true
|
||||
}
|
||||
if origin == "" && r.URL.Query().Get("token") != "" {
|
||||
return true
|
||||
}
|
||||
enabled, _ := config.GetAs[bool](config.WsOriginCheckEnabledKey, true)
|
||||
if !enabled {
|
||||
return true
|
||||
}
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
if security.OriginMatchesHost(origin, r.Host) {
|
||||
return true
|
||||
}
|
||||
allowlist, _ := config.GetAs[string](config.WsAllowedOriginsKey, "")
|
||||
return security.OriginInAllowlist(origin, allowlist)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"image/png"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
)
|
||||
|
||||
func AddClient(c *gin.Context) {
|
||||
@@ -68,8 +68,8 @@ func RemoveClient(c *gin.Context) {
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
ws.DeleteLatestReport(uuid)
|
||||
agent_runtime.DeleteConnectedClients(uuid)
|
||||
agent_runtime.DeleteLatestReport(uuid)
|
||||
}
|
||||
|
||||
func ClearRecord(c *gin.Context) {
|
||||
@@ -2,10 +2,10 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
func OrderWeight(c *gin.Context) {
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// GetClipboard retrieves a clipboard entry by ID
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
const cloudflaredStopConfirmText = "STOP CLOUDFLARED"
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// copyFile 复制单个文件到目标路径(会确保父目录存在)
|
||||
@@ -123,7 +123,7 @@ func DownloadBackup(c *gin.Context) {
|
||||
destDB := filepath.Join(tempDir, "komari.db")
|
||||
dbFilePath := flags.DatabaseFile
|
||||
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
if flags.IsSQLite() {
|
||||
if err := backupSQLiteTo(destDB); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
|
||||
return
|
||||
@@ -2,16 +2,18 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
agent_runtime "github.com/komari-monitor/komari/web/agent"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// 接受数据类型:
|
||||
@@ -23,12 +25,17 @@ func Exec(c *gin.Context) {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
var queuedClients []string
|
||||
var offlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
// for uuid := range ws.GetConnectedClients() {
|
||||
if strings.TrimSpace(req.Command) == "" {
|
||||
api.RespondError(c, 400, "Command cannot be empty")
|
||||
return
|
||||
}
|
||||
// for uuid := range agent_runtime.GetConnectedClients() {
|
||||
// if contain(req.Clients, uuid) {
|
||||
// onlineClients = append(onlineClients, uuid)
|
||||
// }
|
||||
@@ -38,33 +45,36 @@ func Exec(c *gin.Context) {
|
||||
// // }
|
||||
// }
|
||||
for _, uuid := range req.Clients {
|
||||
if client := ws.GetConnectedClients()[uuid]; client != nil {
|
||||
if client := agent_runtime.GetConnectedClients()[uuid]; client != nil {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else if agent_runtime.IsAgentOnline(uuid) {
|
||||
queuedClients = append(queuedClients, uuid)
|
||||
} else {
|
||||
offlineClients = append(offlineClients, uuid)
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
if len(onlineClients) == 0 && len(queuedClients) == 0 {
|
||||
api.RespondError(c, 400, "No clients connected")
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
taskClients := append(append([]string{}, onlineClients...), queuedClients...)
|
||||
taskClients = append(taskClients, offlineClients...)
|
||||
if err := tasks.CreateTask(taskId, taskClients, req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, uuid := range onlineClients {
|
||||
var send struct {
|
||||
legacy := struct {
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command"`
|
||||
TaskId string `json:"task_id"`
|
||||
}{Message: "exec", Command: req.Command, TaskId: taskId}
|
||||
payload, _ := json.Marshal(legacy)
|
||||
if agent_runtime.IsV2Client(uuid) {
|
||||
payload, _ = json.Marshal(v2.Request{JSONRPC: v2.Version, Method: v2.MethodAgentExec, Params: v2.ExecParams{TaskID: taskId, Command: req.Command}})
|
||||
}
|
||||
send.Message = "exec"
|
||||
send.Command = req.Command
|
||||
send.TaskId = taskId
|
||||
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.GetConnectedClients()[uuid]
|
||||
client := agent_runtime.GetConnectedClients()[uuid]
|
||||
if client != nil {
|
||||
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
api.RespondError(c, 400, "Client connection is broke: "+uuid)
|
||||
@@ -75,11 +85,15 @@ func Exec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, uuid := range queuedClients {
|
||||
agent_runtime.DispatchV2Event(uuid, v2.MethodAgentExec, v2.ExecParams{TaskID: taskId, Command: req.Command})
|
||||
}
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
|
||||
api.RespondSuccess(c, gin.H{
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
"queued_clients": queuedClients,
|
||||
})
|
||||
if len(offlineClients) > 0 {
|
||||
for _, uuid := range offlineClients {
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
func GetLogs(c *gin.Context) {
|
||||
@@ -2,12 +2,12 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
func GetMessageSenderProvider(c *gin.Context) {
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
|
||||
@@ -2,9 +2,9 @@ package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/notification"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// ListTrafficReportNotifications GET /api/admin/notification/traffic-report
|
||||
func ListTrafficReportNotifications(c *gin.Context) {
|
||||
notifications, err := notification.ListTrafficReportNotifications()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to list traffic report notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, notifications)
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications POST /api/admin/notification/traffic-report/edit
|
||||
// Body: []TrafficReportNotification
|
||||
func EditTrafficReportNotifications(c *gin.Context) {
|
||||
var notifications []models.TrafficReportNotification
|
||||
if err := c.ShouldBindJSON(¬ifications); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
api.RespondError(c, 400, "At least one notification is required")
|
||||
return
|
||||
}
|
||||
if err := notification.ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
api.RespondError(c, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := notification.EditTrafficReportNotifications(notifications); err != nil {
|
||||
api.RespondError(c, 500, "Failed to edit traffic report notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications POST /api/admin/notification/traffic-report/enable
|
||||
// Body: []string (uuid list)
|
||||
func EnableTrafficReportNotifications(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := notification.EnableTrafficReportNotifications(uuids); err != nil {
|
||||
api.RespondError(c, 500, "Failed to enable traffic report notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications POST /api/admin/notification/traffic-report/disable
|
||||
// Body: []string (uuid list)
|
||||
func DisableTrafficReportNotifications(c *gin.Context) {
|
||||
var uuids []string
|
||||
if err := c.ShouldBindJSON(&uuids); err != nil {
|
||||
api.RespondError(c, 400, "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := notification.DisableTrafficReportNotifications(uuids); err != nil {
|
||||
api.RespondError(c, 500, "Failed to disable traffic report notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/web/oauth"
|
||||
"github.com/komari-monitor/komari/web/oauth/factory"
|
||||
)
|
||||
|
||||
func BindingExternalAccount(c *gin.Context) {
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// AddPingTask 处理新增延迟监测任务请求。
|
||||
@@ -1,9 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultXtermJSFontFamily = "'Cascadia Mono', 'Noto Sans SC', monospace"
|
||||
defaultXtermJSFontSize = 16
|
||||
defaultXtermJSScrollback = 5000
|
||||
defaultXtermJSPadding = 16
|
||||
)
|
||||
|
||||
// XtermJSSettings stores the admin-configurable xtermjs settings payload.
|
||||
type XtermJSSettings struct {
|
||||
TerminalOptions *TerminalOptions `json:"terminalOptions"`
|
||||
TerminalPadding *int `json:"terminalPadding"`
|
||||
TransparentBackground bool `json:"transparentBackground"`
|
||||
CustomCss string `json:"customCss"`
|
||||
}
|
||||
|
||||
// TerminalOptions stores the xterm.js terminal options.
|
||||
type TerminalOptions struct {
|
||||
CursorBlink *bool `json:"cursorBlink"`
|
||||
ConvertEol *bool `json:"convertEol"`
|
||||
FontFamily string `json:"fontFamily"`
|
||||
FontSize int `json:"fontSize"`
|
||||
MacOptionIsMeta *bool `json:"macOptionIsMeta"`
|
||||
Scrollback *int `json:"scrollback"`
|
||||
Theme *ThemeConfig `json:"theme"`
|
||||
}
|
||||
|
||||
// ThemeConfig contains only the xterm.js theme whitelist from the API contract.
|
||||
type ThemeConfig struct {
|
||||
Foreground string `json:"foreground,omitempty"`
|
||||
Background string `json:"background,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
CursorAccent string `json:"cursorAccent,omitempty"`
|
||||
SelectionForeground string `json:"selectionForeground,omitempty"`
|
||||
SelectionBackground string `json:"selectionBackground,omitempty"`
|
||||
SelectionInactiveBackground string `json:"selectionInactiveBackground,omitempty"`
|
||||
Black string `json:"black,omitempty"`
|
||||
Red string `json:"red,omitempty"`
|
||||
Green string `json:"green,omitempty"`
|
||||
Yellow string `json:"yellow,omitempty"`
|
||||
Blue string `json:"blue,omitempty"`
|
||||
Magenta string `json:"magenta,omitempty"`
|
||||
Cyan string `json:"cyan,omitempty"`
|
||||
White string `json:"white,omitempty"`
|
||||
BrightBlack string `json:"brightBlack,omitempty"`
|
||||
BrightRed string `json:"brightRed,omitempty"`
|
||||
BrightGreen string `json:"brightGreen,omitempty"`
|
||||
BrightYellow string `json:"brightYellow,omitempty"`
|
||||
BrightBlue string `json:"brightBlue,omitempty"`
|
||||
BrightMagenta string `json:"brightMagenta,omitempty"`
|
||||
BrightCyan string `json:"brightCyan,omitempty"`
|
||||
BrightWhite string `json:"brightWhite,omitempty"`
|
||||
ExtendedAnsi []string `json:"extendedAnsi,omitempty"`
|
||||
}
|
||||
|
||||
func defaultXtermJSSettings() XtermJSSettings {
|
||||
return XtermJSSettings{
|
||||
TerminalOptions: &TerminalOptions{
|
||||
CursorBlink: boolPtr(true),
|
||||
ConvertEol: boolPtr(true),
|
||||
FontFamily: defaultXtermJSFontFamily,
|
||||
FontSize: defaultXtermJSFontSize,
|
||||
MacOptionIsMeta: boolPtr(true),
|
||||
Scrollback: intPtr(defaultXtermJSScrollback),
|
||||
Theme: nil,
|
||||
},
|
||||
TerminalPadding: intPtr(defaultXtermJSPadding),
|
||||
TransparentBackground: false,
|
||||
CustomCss: "",
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeXtermJSSettings(input XtermJSSettings) (XtermJSSettings, error) {
|
||||
normalized := defaultXtermJSSettings()
|
||||
|
||||
if input.TerminalOptions != nil {
|
||||
options := *input.TerminalOptions
|
||||
options.CursorBlink = boolOrDefault(input.TerminalOptions.CursorBlink, true)
|
||||
options.ConvertEol = boolOrDefault(input.TerminalOptions.ConvertEol, true)
|
||||
options.MacOptionIsMeta = boolOrDefault(input.TerminalOptions.MacOptionIsMeta, true)
|
||||
|
||||
if fontFamily := strings.TrimSpace(input.TerminalOptions.FontFamily); fontFamily != "" {
|
||||
options.FontFamily = fontFamily
|
||||
} else {
|
||||
options.FontFamily = defaultXtermJSFontFamily
|
||||
}
|
||||
|
||||
if input.TerminalOptions.FontSize >= 1 {
|
||||
options.FontSize = input.TerminalOptions.FontSize
|
||||
} else {
|
||||
options.FontSize = defaultXtermJSFontSize
|
||||
}
|
||||
|
||||
if input.TerminalOptions.Scrollback != nil && *input.TerminalOptions.Scrollback >= 0 {
|
||||
options.Scrollback = intPtr(*input.TerminalOptions.Scrollback)
|
||||
} else {
|
||||
options.Scrollback = intPtr(defaultXtermJSScrollback)
|
||||
}
|
||||
|
||||
theme, err := normalizeThemeConfig(input.TerminalOptions.Theme)
|
||||
if err != nil {
|
||||
return XtermJSSettings{}, err
|
||||
}
|
||||
options.Theme = theme
|
||||
|
||||
normalized.TerminalOptions = &options
|
||||
}
|
||||
|
||||
if input.TerminalPadding != nil && *input.TerminalPadding >= 0 {
|
||||
normalized.TerminalPadding = intPtr(*input.TerminalPadding)
|
||||
}
|
||||
normalized.TransparentBackground = input.TransparentBackground
|
||||
normalized.CustomCss = input.CustomCss
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeThemeConfig(input *ThemeConfig) (*ThemeConfig, error) {
|
||||
if input == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
normalized := &ThemeConfig{
|
||||
Foreground: cleanThemeColor(input.Foreground),
|
||||
Background: cleanThemeColor(input.Background),
|
||||
Cursor: cleanThemeColor(input.Cursor),
|
||||
CursorAccent: cleanThemeColor(input.CursorAccent),
|
||||
SelectionForeground: cleanThemeColor(input.SelectionForeground),
|
||||
SelectionBackground: cleanThemeColor(input.SelectionBackground),
|
||||
SelectionInactiveBackground: cleanThemeColor(input.SelectionInactiveBackground),
|
||||
Black: cleanThemeColor(input.Black),
|
||||
Red: cleanThemeColor(input.Red),
|
||||
Green: cleanThemeColor(input.Green),
|
||||
Yellow: cleanThemeColor(input.Yellow),
|
||||
Blue: cleanThemeColor(input.Blue),
|
||||
Magenta: cleanThemeColor(input.Magenta),
|
||||
Cyan: cleanThemeColor(input.Cyan),
|
||||
White: cleanThemeColor(input.White),
|
||||
BrightBlack: cleanThemeColor(input.BrightBlack),
|
||||
BrightRed: cleanThemeColor(input.BrightRed),
|
||||
BrightGreen: cleanThemeColor(input.BrightGreen),
|
||||
BrightYellow: cleanThemeColor(input.BrightYellow),
|
||||
BrightBlue: cleanThemeColor(input.BrightBlue),
|
||||
BrightMagenta: cleanThemeColor(input.BrightMagenta),
|
||||
BrightCyan: cleanThemeColor(input.BrightCyan),
|
||||
BrightWhite: cleanThemeColor(input.BrightWhite),
|
||||
}
|
||||
|
||||
if len(input.ExtendedAnsi) > 0 {
|
||||
normalized.ExtendedAnsi = make([]string, 0, len(input.ExtendedAnsi))
|
||||
for _, entry := range input.ExtendedAnsi {
|
||||
trimmed := strings.TrimSpace(entry)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("extendedAnsi must not contain empty strings")
|
||||
}
|
||||
normalized.ExtendedAnsi = append(normalized.ExtendedAnsi, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if themeIsEmpty(normalized) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolOrDefault(value *bool, defaultValue bool) *bool {
|
||||
if value == nil {
|
||||
return boolPtr(defaultValue)
|
||||
}
|
||||
return boolPtr(*value)
|
||||
}
|
||||
|
||||
func cleanThemeColor(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func themeIsEmpty(theme *ThemeConfig) bool {
|
||||
if theme == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return theme.Foreground == "" &&
|
||||
theme.Background == "" &&
|
||||
theme.Cursor == "" &&
|
||||
theme.CursorAccent == "" &&
|
||||
theme.SelectionForeground == "" &&
|
||||
theme.SelectionBackground == "" &&
|
||||
theme.SelectionInactiveBackground == "" &&
|
||||
theme.Black == "" &&
|
||||
theme.Red == "" &&
|
||||
theme.Green == "" &&
|
||||
theme.Yellow == "" &&
|
||||
theme.Blue == "" &&
|
||||
theme.Magenta == "" &&
|
||||
theme.Cyan == "" &&
|
||||
theme.White == "" &&
|
||||
theme.BrightBlack == "" &&
|
||||
theme.BrightRed == "" &&
|
||||
theme.BrightGreen == "" &&
|
||||
theme.BrightYellow == "" &&
|
||||
theme.BrightBlue == "" &&
|
||||
theme.BrightMagenta == "" &&
|
||||
theme.BrightCyan == "" &&
|
||||
theme.BrightWhite == "" &&
|
||||
len(theme.ExtendedAnsi) == 0
|
||||
}
|
||||
|
||||
func GetXtermJSSettings(c *gin.Context) {
|
||||
defaultSettings := defaultXtermJSSettings()
|
||||
settings, err := config.GetAs[XtermJSSettings](config.XtermjsSettingsKey, defaultSettings)
|
||||
if err != nil {
|
||||
_ = config.Set(config.XtermjsSettingsKey, defaultSettings)
|
||||
settings = defaultSettings
|
||||
}
|
||||
|
||||
normalized, err := normalizeXtermJSSettings(settings)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get xtermjs settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, normalized)
|
||||
}
|
||||
|
||||
func SetXtermJSSettings(c *gin.Context) {
|
||||
var settings XtermJSSettings
|
||||
if err := c.ShouldBindJSON(&settings); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
normalized, err := normalizeXtermJSSettings(settings)
|
||||
if err != nil {
|
||||
api.RespondError(c, 400, "Invalid xtermjs settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.Set(config.XtermjsSettingsKey, normalized); err != nil {
|
||||
api.RespondError(c, 500, "Failed to save settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), uuid.(string), "update xtermjs settings", "info")
|
||||
api.RespondSuccessMessage(c, "settings saved", nil)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Do not use t.Parallel(): config.SetDb mutates package-global state.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var xtermJSAuditDBOnce sync.Once
|
||||
|
||||
type xtermJSAPIResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func ensureXtermJSAuditDB() {
|
||||
xtermJSAuditDBOnce.Do(func() {
|
||||
flags.DatabaseType = "sqlite"
|
||||
flags.DatabaseFile = "file:xtermjs_audit?mode=memory&cache=shared"
|
||||
_ = dbcore.GetDBInstance()
|
||||
})
|
||||
}
|
||||
|
||||
func setupXtermJSTestDB(t *testing.T) (*gorm.DB, *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
testDBName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
testDSN := fmt.Sprintf("file:%s?mode=memory&cache=shared", testDBName)
|
||||
|
||||
testDB, err := gorm.Open(sqlite.Open(testDSN), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlDB, err := testDB.DB()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initialize the shared audit DB first, then restore the per-test config DB.
|
||||
ensureXtermJSAuditDB()
|
||||
config.SetDb(testDB)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
return testDB, sqlDB
|
||||
}
|
||||
|
||||
func newXtermJSRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("uuid", "test-uuid")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/xtermjs", GetXtermJSSettings)
|
||||
router.POST("/xtermjs", SetXtermJSSettings)
|
||||
return router
|
||||
}
|
||||
|
||||
func performXtermJSRequest(t *testing.T, router *gin.Engine, method, target string, body []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(method, target, bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeXtermJSResponse(t *testing.T, w *httptest.ResponseRecorder) xtermJSAPIResponse {
|
||||
t.Helper()
|
||||
|
||||
var resp xtermJSAPIResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeXtermJSSettings(t *testing.T, raw json.RawMessage) XtermJSSettings {
|
||||
t.Helper()
|
||||
|
||||
var settings XtermJSSettings
|
||||
require.NoError(t, json.Unmarshal(raw, &settings))
|
||||
return settings
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestGetXtermJSSettingsReturnsDefaultWhenMissing(t *testing.T) {
|
||||
testDB, _ := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
resp := decodeXtermJSResponse(t, w)
|
||||
assert.Equal(t, "success", resp.Status)
|
||||
assert.Empty(t, resp.Message)
|
||||
|
||||
got := decodeXtermJSSettings(t, resp.Data)
|
||||
expected := defaultXtermJSSettings()
|
||||
assert.Equal(t, expected, got)
|
||||
|
||||
var stored config.ConfigItem
|
||||
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
|
||||
|
||||
var persisted XtermJSSettings
|
||||
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
|
||||
assert.Equal(t, expected, persisted)
|
||||
}
|
||||
|
||||
func TestXtermJSSettingsRoundTripAndThemeVariants(t *testing.T) {
|
||||
t.Run("full config round trip", func(t *testing.T) {
|
||||
setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
input := XtermJSSettings{
|
||||
TerminalOptions: &TerminalOptions{
|
||||
CursorBlink: ptr(false),
|
||||
ConvertEol: ptr(false),
|
||||
FontFamily: "JetBrains Mono",
|
||||
FontSize: 18,
|
||||
MacOptionIsMeta: ptr(false),
|
||||
Scrollback: ptr(9000),
|
||||
Theme: &ThemeConfig{
|
||||
Foreground: "#f8f8f2",
|
||||
Background: "#282a36",
|
||||
Cursor: "#f8f8f2",
|
||||
SelectionBackground: "#44475a",
|
||||
Blue: "#6272a4",
|
||||
ExtendedAnsi: []string{"#111111", "#222222"},
|
||||
},
|
||||
},
|
||||
TerminalPadding: ptr(20),
|
||||
TransparentBackground: true,
|
||||
CustomCss: "body { color: red; }",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
resp := decodeXtermJSResponse(t, w)
|
||||
assert.Equal(t, "success", resp.Status)
|
||||
assert.Equal(t, "settings saved", resp.Message)
|
||||
|
||||
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
|
||||
got := decodeXtermJSSettings(t, getResp.Data)
|
||||
assert.Equal(t, input, got)
|
||||
})
|
||||
|
||||
t.Run("theme null stays null", func(t *testing.T) {
|
||||
setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
body := []byte(`{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":17,"macOptionIsMeta":true,"scrollback":1000,"theme":null},"terminalPadding":16,"transparentBackground":false,"customCss":"."}`)
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
|
||||
require.NotNil(t, got.TerminalOptions)
|
||||
assert.Nil(t, got.TerminalOptions.Theme)
|
||||
})
|
||||
|
||||
t.Run("terminalOptions null uses defaults", func(t *testing.T) {
|
||||
setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
body := []byte(`{"terminalOptions":null,"terminalPadding":24,"transparentBackground":true,"customCss":"body { background: black; }"}`)
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
|
||||
expected := defaultXtermJSSettings()
|
||||
expected.TerminalPadding = ptr(24)
|
||||
expected.TransparentBackground = true
|
||||
expected.CustomCss = "body { background: black; }"
|
||||
assert.Equal(t, expected, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestXtermJSSettingsUnknownThemeFieldsAreDropped(t *testing.T) {
|
||||
testDB, _ := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":{"foreground":"#ffffff","unknownField":"keep-out","extendedAnsi":["#111111"]}}}`)
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
resp := decodeXtermJSResponse(t, w)
|
||||
assert.Equal(t, "success", resp.Status)
|
||||
|
||||
getResp := decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil))
|
||||
got := decodeXtermJSSettings(t, getResp.Data)
|
||||
require.NotNil(t, got.TerminalOptions)
|
||||
require.NotNil(t, got.TerminalOptions.Theme)
|
||||
assert.Equal(t, "#ffffff", got.TerminalOptions.Theme.Foreground)
|
||||
|
||||
var stored config.ConfigItem
|
||||
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
|
||||
assert.NotContains(t, stored.Value, "unknownField")
|
||||
}
|
||||
|
||||
func TestXtermJSSettingsValidationAndFallbacks(t *testing.T) {
|
||||
t.Run("extendedAnsi and body validation", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[""]}}}`),
|
||||
},
|
||||
{
|
||||
name: "space string",
|
||||
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[" "]}}}`),
|
||||
},
|
||||
{
|
||||
name: "illegal type",
|
||||
body: []byte(`{"terminalOptions":{"theme":{"extendedAnsi":[1]}}}`),
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
body: []byte(`{"terminalOptions":`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", tc.body)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
resp := decodeXtermJSResponse(t, w)
|
||||
assert.Equal(t, "error", resp.Status)
|
||||
assert.NotEmpty(t, resp.Message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallbacks are normalized on save and read", func(t *testing.T) {
|
||||
testDB, _ := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
neg := -1
|
||||
body, err := json.Marshal(XtermJSSettings{
|
||||
TerminalOptions: &TerminalOptions{
|
||||
CursorBlink: ptr(true),
|
||||
ConvertEol: ptr(true),
|
||||
FontFamily: " ",
|
||||
FontSize: 0,
|
||||
MacOptionIsMeta: ptr(true),
|
||||
Scrollback: &neg,
|
||||
Theme: &ThemeConfig{
|
||||
Foreground: " ",
|
||||
},
|
||||
},
|
||||
TerminalPadding: &neg,
|
||||
TransparentBackground: true,
|
||||
CustomCss: "body { color: blue; }",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)).Data)
|
||||
expected := defaultXtermJSSettings()
|
||||
expected.TransparentBackground = true
|
||||
expected.CustomCss = "body { color: blue; }"
|
||||
assert.Equal(t, expected, got)
|
||||
|
||||
var stored config.ConfigItem
|
||||
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
|
||||
var persisted XtermJSSettings
|
||||
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
|
||||
assert.Equal(t, expected, persisted)
|
||||
})
|
||||
|
||||
t.Run("corrupt JSON is repaired on get", func(t *testing.T) {
|
||||
testDB, _ := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
raw := `{"terminalOptions":`
|
||||
require.NoError(t, testDB.Create(&config.ConfigItem{
|
||||
Key: config.XtermjsSettingsKey,
|
||||
Value: raw,
|
||||
}).Error)
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
|
||||
expected := defaultXtermJSSettings()
|
||||
assert.Equal(t, expected, got)
|
||||
|
||||
var stored config.ConfigItem
|
||||
require.NoError(t, testDB.First(&stored, "key = ?", config.XtermjsSettingsKey).Error)
|
||||
var persisted XtermJSSettings
|
||||
require.NoError(t, json.Unmarshal([]byte(stored.Value), &persisted))
|
||||
assert.Equal(t, expected, persisted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestXtermJSSettingsNormalizesSeededLegacyConfig(t *testing.T) {
|
||||
testDB, _ := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
seeded := `{"terminalOptions":{"cursorBlink":true,"convertEol":true,"fontFamily":"JetBrains Mono","fontSize":0,"macOptionIsMeta":true,"scrollback":1200,"theme":null},"terminalPadding":12,"transparentBackground":true,"customCss":"seeded"}`
|
||||
require.NoError(t, testDB.Create(&config.ConfigItem{
|
||||
Key: config.XtermjsSettingsKey,
|
||||
Value: seeded,
|
||||
}).Error)
|
||||
|
||||
w := performXtermJSRequest(t, router, http.MethodGet, "/xtermjs", nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
got := decodeXtermJSSettings(t, decodeXtermJSResponse(t, w).Data)
|
||||
expected := XtermJSSettings{
|
||||
TerminalOptions: &TerminalOptions{
|
||||
CursorBlink: ptr(true),
|
||||
ConvertEol: ptr(true),
|
||||
FontFamily: "JetBrains Mono",
|
||||
FontSize: 16,
|
||||
MacOptionIsMeta: ptr(true),
|
||||
Scrollback: ptr(1200),
|
||||
Theme: nil,
|
||||
},
|
||||
TerminalPadding: ptr(12),
|
||||
TransparentBackground: true,
|
||||
CustomCss: "seeded",
|
||||
}
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestXtermJSSettingsSaveFailureReturns500(t *testing.T) {
|
||||
_, sqlDB := setupXtermJSTestDB(t)
|
||||
router := newXtermJSRouter()
|
||||
|
||||
require.NoError(t, sqlDB.Close())
|
||||
|
||||
body := []byte(`{"terminalOptions":{"fontFamily":"JetBrains Mono","fontSize":16,"theme":null}}`)
|
||||
w := performXtermJSRequest(t, router, http.MethodPost, "/xtermjs", body)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
resp := decodeXtermJSResponse(t, w)
|
||||
assert.Equal(t, "error", resp.Status)
|
||||
assert.Contains(t, resp.Message, "Failed to save settings:")
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
func GetTasks(c *gin.Context) {
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"net"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
func TestSendMessage(c *gin.Context) {
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
"github.com/komari-monitor/komari/pkg/config"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
"github.com/komari-monitor/komari/web/public"
|
||||
)
|
||||
|
||||
// UploadTheme 上传主题
|
||||
@@ -202,6 +202,10 @@ func extractAndValidateTheme(zipPath string) (models.Theme, error) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
if err := themeInfo.ValidateConfiguration(); err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
// 创建主题目录
|
||||
themeDir := filepath.Join("./data/theme", themeInfo.Short)
|
||||
|
||||
@@ -671,6 +675,10 @@ func peekThemeFromZip(zipPath string) (models.Theme, error) {
|
||||
return themeInfo, fmt.Errorf("主题short字段格式无效,只允许字母、数字、下划线和连字符")
|
||||
}
|
||||
|
||||
if err := themeInfo.ValidateConfiguration(); err != nil {
|
||||
return themeInfo, err
|
||||
}
|
||||
|
||||
return themeInfo, nil
|
||||
}
|
||||
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user