diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index 4039ce5..b58adfc 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -217,13 +217,7 @@ func (a *AgentController) reportTraffic(c *gin.Context) { } } if agent.NodeID != nil { - if len(req.Onlines) > 0 { - a.inboundService.SetNodeOnlineTree(*agent.NodeID, map[string][]string{ - "agent": req.Onlines, - }) - } else { - a.inboundService.ClearNodeOnlineClients(*agent.NodeID) - } + a.inboundService.SetAgentOnlineClients(*agent.NodeID, req.Onlines) } a.touchAgent(uint(agent.Id), "") jsonObj(c, gin.H{"message": "ok"}, nil) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 7aa194c..b7ad67b 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -1366,6 +1366,31 @@ func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) sharedOnlineStore.SetNodeOnlineTree(nodeID, tree) } +// SetAgentOnlineClients records the online clients an agent node reports in +// its traffic payload. An agent has no GUID tree of its own, so its clients +// are keyed under the node's effective attribution key (effectiveNodeKey — +// the same key setRemoteTrafficLocked computes for panel nodes), which is +// what the inbounds page looks up via the inbound's originNodeGuid / +// node:{id} fallback. Storing them under a literal "agent" key (the old +// controller behaviour) left agent inbounds permanently dark on that page. +// An empty list is the agent's disconnect signal and clears the node's +// whole online contribution. +func (s *InboundService) SetAgentOnlineClients(nodeID int, onlines []string) { + if len(onlines) == 0 { + s.ClearNodeOnlineClients(nodeID) + return + } + key := synthNodeGuid(nodeID) + var nodeRow model.Node + if err := database.GetDB().Select("guid").Where("id = ?", nodeID).First(&nodeRow).Error; err == nil { + nodeRow.Id = nodeID + if k := effectiveNodeKey(&nodeRow); k != "" { + key = k + } + } + s.SetNodeOnlineTree(nodeID, map[string][]string{key: onlines}) +} + func (s *InboundService) SetNodeActiveInboundTree(nodeID int, tree map[string][]string) { sharedOnlineStore.SetNodeActiveInboundTree(nodeID, tree) } diff --git a/internal/web/service/inbound_node_agent_online_test.go b/internal/web/service/inbound_node_agent_online_test.go new file mode 100644 index 0000000..76fabd9 --- /dev/null +++ b/internal/web/service/inbound_node_agent_online_test.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Agent nodes report their online clients inside the traffic payload; the +// master files them via InboundService.SetAgentOnlineClients. They must be +// keyed under the node's effective attribution key — pre-fix the controller +// stored every agent's list under one literal "agent" key, which the +// inbounds page (lookup by originNodeGuid / node:{id}) never matches, so +// agent inbounds never lit anyone online and all agents merged into one +// bucket. These tests pin the per-node keying. +func TestSetAgentOnlineClients_AttributionKeysPerNode(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + svc := InboundService{} + + const nodeA = 7301 + const nodeB = 7302 + for _, id := range []int{nodeA, nodeB} { + svc.ClearNodeOnlineClients(id) + if err := db.Create(&model.Node{Id: id, Name: fmt.Sprintf("agent-node-%d", id), Address: "10.0.7.3"}).Error; err != nil { + t.Fatalf("create node %d: %v", id, err) + } + } + t.Cleanup(func() { + svc.ClearNodeOnlineClients(nodeA) + svc.ClearNodeOnlineClients(nodeB) + }) + + svc.SetAgentOnlineClients(nodeA, []string{"agent-a@example.com"}) + svc.SetAgentOnlineClients(nodeB, []string{"agent-b@example.com"}) + + keyA := effectiveNodeKey(&model.Node{Id: nodeA}) + keyB := effectiveNodeKey(&model.Node{Id: nodeB}) + if keyA == keyB { + t.Fatalf("effective keys collide: %q", keyA) + } + + byGuid := svc.GetOnlineClientsByGuid() + assertStringSet(t, "byGuid node A", byGuid[keyA], []string{"agent-a@example.com"}) + assertStringSet(t, "byGuid node B", byGuid[keyB], []string{"agent-b@example.com"}) + if _, ok := byGuid["agent"]; ok { + t.Fatalf("byGuid still carries the legacy literal \"agent\" key: %v", byGuid) + } + + union := svc.GetOnlineClients() + for _, want := range []string{"agent-a@example.com", "agent-b@example.com"} { + found := false + for _, got := range union { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("online union %v missing %q", union, want) + } + } +} + +// An empty onlines list is the agent's disconnect signal: the node's +// clients must stop showing as online immediately. +func TestSetAgentOnlineClients_EmptyReportClears(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + svc := InboundService{} + + const nodeID = 7303 + svc.ClearNodeOnlineClients(nodeID) + t.Cleanup(func() { svc.ClearNodeOnlineClients(nodeID) }) + if err := db.Create(&model.Node{Id: nodeID, Name: "agent-node-7303", Address: "10.0.7.4"}).Error; err != nil { + t.Fatalf("create node: %v", err) + } + + svc.SetAgentOnlineClients(nodeID, []string{"gone@example.com"}) + key := effectiveNodeKey(&model.Node{Id: nodeID}) + assertStringSet(t, "byGuid before clear", svc.GetOnlineClientsByGuid()[key], []string{"gone@example.com"}) + + svc.SetAgentOnlineClients(nodeID, nil) + if got := svc.GetOnlineClientsByGuid()[key]; len(got) != 0 { + t.Fatalf("byGuid[%q] after empty report = %v, want empty", key, got) + } + for _, got := range svc.GetOnlineClients() { + if got == "gone@example.com" { + t.Fatalf("online union still contains cleared client: %v", svc.GetOnlineClients()) + } + } +} diff --git a/internal/web/service/online_store_test.go b/internal/web/service/online_store_test.go index 1256784..08d73cd 100644 --- a/internal/web/service/online_store_test.go +++ b/internal/web/service/online_store_test.go @@ -55,13 +55,15 @@ func TestOnlineStoreSetTreeUnionAndByGuid(t *testing.T) { requireSet(t, "local", st.GetLocalOnlineClients(), "alice@example.com", "local@example.com") } -// The agent controller reports onlines under the literal "agent" GUID key; +// Agent-node onlines arrive keyed by the node's attribution key (the +// synthNodeGuid form below for a GUID-less agent node — the controller used +// to file them under a literal "agent" key no inbound lookup ever matched); // they must reach the union untouched. func TestOnlineStoreAgentKeyTree(t *testing.T) { st := &onlineStore{} - st.SetNodeOnlineTree(7, map[string][]string{"agent": {"agentuser@example.com"}}) + st.SetNodeOnlineTree(7, map[string][]string{"node:7": {"agentuser@example.com"}}) requireSet(t, "union", st.GetOnlineClients(), "agentuser@example.com") - requireSet(t, "byGuid agent", st.GetMergedNodeTrees()["agent"], "agentuser@example.com") + requireSet(t, "byGuid node:7", st.GetMergedNodeTrees()["node:7"], "agentuser@example.com") } // Setting a tree replaces the node's previous contribution; an empty tree