feat(telemetry): agent 心跳遥测上报+节点列表覆写(第13条)

agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/
uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底);
主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变;
agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值,
panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
This commit is contained in:
Muse
2026-10-22 11:33:56 +00:00
parent 84d7b85be7
commit 3536d4174e
10 changed files with 604 additions and 6 deletions
+55
View File
@@ -117,13 +117,68 @@ func (a *AgentController) register(c *gin.Context) {
jsonObj(c, gin.H{"message": "registered"}, nil)
}
// heartbeatTelemetry is the optional telemetry object an agent piggybacks
// on its heartbeat (P13). Every field is a pointer: a missing field means
// "not reported this round" and must not overwrite the stored value. Older
// agents send no telemetry object at all and are handled exactly as before.
type heartbeatTelemetry struct {
AgentVersion *string `json:"agentVersion"`
XrayVersion *string `json:"xrayVersion"`
UptimeSeconds *uint64 `json:"uptimeSeconds"`
LatencyMs *int `json:"latencyMs"`
CpuPercent *float64 `json:"cpuPercent"`
MemPercent *float64 `json:"memPercent"`
IPv4 *string `json:"ipv4"`
IPv6 *string `json:"ipv6"`
}
// updateAgentTelemetry persists the reported telemetry fields onto the
// agent row. Only fields actually present in the report are written.
func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
updates := map[string]any{"telemetry_at": time.Now().UnixMilli()}
if t.AgentVersion != nil {
updates["agent_version"] = *t.AgentVersion
}
if t.XrayVersion != nil {
updates["xray_version"] = *t.XrayVersion
}
if t.UptimeSeconds != nil {
updates["uptime_secs"] = *t.UptimeSeconds
}
if t.LatencyMs != nil {
updates["latency_ms"] = *t.LatencyMs
}
if t.CpuPercent != nil {
updates["cpu_pct"] = *t.CpuPercent
}
if t.MemPercent != nil {
updates["mem_pct"] = *t.MemPercent
}
if t.IPv4 != nil {
updates["ipv4"] = *t.IPv4
}
if t.IPv6 != nil {
updates["ipv6"] = *t.IPv6
}
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
}
func (a *AgentController) heartbeat(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
c.JSON(401, gin.H{"message": "unauthorized"})
return
}
// Telemetry is optional and best-effort: a body that fails to parse
// (or no body at all) must never fail the heartbeat itself.
var req struct {
Telemetry *heartbeatTelemetry `json:"telemetry"`
}
_ = c.ShouldBindJSON(&req)
a.touchAgent(uint(agent.Id), "")
if req.Telemetry != nil {
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
@@ -0,0 +1,96 @@
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)
}
}
+3 -1
View File
@@ -362,7 +362,9 @@ func (s *NodeService) GetViewById(id int) (*NodeView, error) {
return nil, err
}
view := toNodeView(n)
annotateNodeTypes([]*NodeView{view})
views := []*NodeView{view}
annotateNodeTypes(views)
annotateAgentTelemetry(views)
return view, nil
}
@@ -0,0 +1,72 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// P13: annotateAgentTelemetry overlays the agent-reported heartbeat
// telemetry onto agent node views only — panel version becomes
// "agent <version>", xray version/uptime/latency/CPU/mem/IPs take the
// reported values. Traditional rows and never-reporting agents are
// left untouched.
func TestAnnotateAgentTelemetry(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
agentNode := &model.Node{Id: 7401, Name: "agent-node", Address: ""}
tradNode := &model.Node{Id: 7402, Name: "trad-node", Address: "198.51.100.2", PanelVersion: "3.4.2", XrayVersion: "26.6.27"}
silentNode := &model.Node{Id: 7403, Name: "silent-agent-node", Address: ""}
for _, n := range []*model.Node{agentNode, tradNode, silentNode} {
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node %d: %v", n.Id, err)
}
}
reporting := &model.AgentNode{
TokenHash: "hash-reporting",
Name: "agent-node", NodeID: &agentNode.Id,
AgentVersion: "3.7.0-agent.1", XrayVersion: "26.6.27",
UptimeSecs: 90061, LatencyMs: 12, CpuPct: 3.5, MemPct: 41.2,
IPv4: "203.0.113.7", IPv6: "2001:db8::7", TelemetryAt: 1700000000000,
}
silent := &model.AgentNode{TokenHash: "hash-silent", Name: "silent-agent-node", NodeID: &silentNode.Id}
// The traditional node has no agent row at all — by the codebase's
// definition (any binding makes a node agent-managed) it must pass
// through the overlay untouched.
for _, ag := range []*model.AgentNode{reporting, silent} {
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent row: %v", err)
}
}
views := toNodeViews([]*model.Node{agentNode, tradNode, silentNode})
annotateNodeTypes(views)
annotateAgentTelemetry(views)
byID := map[int]*NodeView{}
for _, v := range views {
byID[v.Id] = v
}
av := byID[7401]
if av.NodeType != "agent" {
t.Fatalf("node 7401 type = %q, want agent", av.NodeType)
}
if av.XrayVersion != "26.6.27" || av.PanelVersion != "agent 3.7.0-agent.1" ||
av.UptimeSecs != 90061 || av.LatencyMs != 12 ||
av.CpuPct != 3.5 || av.MemPct != 41.2 ||
av.Ipv4 != "203.0.113.7" || av.Ipv6 != "2001:db8::7" {
t.Fatalf("agent view not overlaid: %+v", av)
}
tv := byID[7402]
if tv.PanelVersion != "3.4.2" || tv.XrayVersion != "26.6.27" || tv.Ipv4 != "" || tv.Ipv6 != "" {
t.Fatalf("traditional view mutated: %+v", tv)
}
sv := byID[7403]
if sv.XrayVersion != "" || sv.PanelVersion != "" || sv.Ipv4 != "" || sv.UptimeSecs != 0 {
t.Fatalf("silent agent view should stay empty: %+v", sv)
}
}
+55 -1
View File
@@ -31,7 +31,13 @@ type NodeView struct {
// agent binding, not stored on the node row; read paths that serve the
// panel UI annotate it via annotateNodeTypes. Empty on views built
// outside those paths.
NodeType string `json:"nodeType,omitempty" example:"traditional"`
NodeType string `json:"nodeType,omitempty" example:"traditional"`
// Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes
// (P13). The nodes page renders them as plain text on agent rows
// instead of the panel-URL link used for traditional nodes. Empty for
// traditional nodes and for agents that never reported telemetry.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
@@ -156,6 +162,54 @@ func annotateNodeTypes(views []*NodeView) {
}
}
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
// by each node's bound agent onto its view (P13): xray version, the
// "agent <version>" panel-version label, host uptime, node->master latency,
// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional
// nodes (and agent nodes whose agent never reported telemetry) are left
// exactly as built. Must run after annotateNodeTypes.
func annotateAgentTelemetry(views []*NodeView) {
if len(views) == 0 {
return
}
var agents []model.AgentNode
// Latest reporter wins when more than one token is bound to a node.
_ = database.GetDB().Model(&model.AgentNode{}).
Where("node_id IS NOT NULL").
Order("last_seen DESC").
Find(&agents).Error
byNode := make(map[int]*model.AgentNode, len(agents))
for i := range agents {
if agents[i].NodeID == nil {
continue
}
if _, ok := byNode[*agents[i].NodeID]; !ok {
byNode[*agents[i].NodeID] = &agents[i]
}
}
for _, v := range views {
if v.NodeType != "agent" {
continue
}
ag, ok := byNode[v.Id]
if !ok || ag.TelemetryAt == 0 {
continue
}
if ag.XrayVersion != "" {
v.XrayVersion = ag.XrayVersion
}
if ag.AgentVersion != "" {
v.PanelVersion = "agent " + ag.AgentVersion
}
v.UptimeSecs = ag.UptimeSecs
v.LatencyMs = ag.LatencyMs
v.CpuPct = ag.CpuPct
v.MemPct = ag.MemPct
v.Ipv4 = ag.IPv4
v.Ipv6 = ag.IPv6
}
}
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
// only as input. On update, nil means keep the stored token; replacement and
// clearing are explicit and mutually exclusive.
+1
View File
@@ -164,6 +164,7 @@ func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) {
}
views := toNodeViews(nodes)
annotateNodeTypes(views)
annotateAgentTelemetry(views)
return views, nil
}