agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/ uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底); 主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变; agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值, panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
97 lines
3.1 KiB
Go
97 lines
3.1 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// P13: the heartbeat optionally carries a telemetry object. Reported fields
|
|
// are persisted onto the agent row; a heartbeat without telemetry (older
|
|
// agents) must not clear previously stored values, and a partial report
|
|
// only overwrites the fields it carries.
|
|
func TestAgentHeartbeatTelemetry(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = database.CloseDB() })
|
|
db := database.GetDB()
|
|
|
|
node := &model.Node{Name: "telemetry-node", Address: "10.0.9.1"}
|
|
if err := db.Create(node).Error; err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
const token = "telemetry-test-token"
|
|
agent := &model.AgentNode{
|
|
TokenHash: hashAgentToken(token),
|
|
TokenPrefix: "telemetry-",
|
|
Name: "telemetry-node",
|
|
NodeID: &node.Id,
|
|
}
|
|
if err := db.Create(agent).Error; err != nil {
|
|
t.Fatalf("seed agent: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
NewAgentController(engine.Group("/panel/api/agent"))
|
|
|
|
post := func(body string) int {
|
|
req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
engine.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
load := func() model.AgentNode {
|
|
var row model.AgentNode
|
|
if err := db.First(&row, agent.Id).Error; err != nil {
|
|
t.Fatalf("reload agent: %v", err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
full := `{"token":"` + token + `","timestamp":1,"telemetry":{` +
|
|
`"agentVersion":"3.7.0-agent.1","xrayVersion":"26.6.27","uptimeSeconds":90061,` +
|
|
`"latencyMs":12,"cpuPercent":3.5,"memPercent":41.2,` +
|
|
`"ipv4":"203.0.113.7","ipv6":"2001:db8::7"}}`
|
|
if code := post(full); code != http.StatusOK {
|
|
t.Fatalf("heartbeat with telemetry: status %d", code)
|
|
}
|
|
row := load()
|
|
if row.AgentVersion != "3.7.0-agent.1" || row.XrayVersion != "26.6.27" ||
|
|
row.UptimeSecs != 90061 || row.LatencyMs != 12 ||
|
|
row.CpuPct != 3.5 || row.MemPct != 41.2 ||
|
|
row.IPv4 != "203.0.113.7" || row.IPv6 != "2001:db8::7" || row.TelemetryAt == 0 {
|
|
t.Fatalf("telemetry not stored: %+v", row)
|
|
}
|
|
|
|
// No telemetry object (old agent): stored values survive.
|
|
if code := post(`{"token":"` + token + `","timestamp":2}`); code != http.StatusOK {
|
|
t.Fatalf("bare heartbeat: status %d", code)
|
|
}
|
|
row = load()
|
|
if row.XrayVersion != "26.6.27" || row.IPv4 != "203.0.113.7" || row.UptimeSecs != 90061 {
|
|
t.Fatalf("bare heartbeat cleared telemetry: %+v", row)
|
|
}
|
|
|
|
// Partial report: only the carried field changes.
|
|
if code := post(`{"token":"` + token + `","timestamp":3,"telemetry":{"cpuPercent":9.25}}`); code != http.StatusOK {
|
|
t.Fatalf("partial heartbeat: status %d", code)
|
|
}
|
|
row = load()
|
|
if row.CpuPct != 9.25 || row.MemPct != 41.2 || row.LatencyMs != 12 {
|
|
t.Fatalf("partial telemetry merged wrong: %+v", row)
|
|
}
|
|
}
|