fix(online): scope online status per node instead of a global union

The inbounds page and Nodes page checked each client's email against a
single deduped union of every node's online clients, so a client connected
to one node showed as online on every inbound across every node. The local
online set was also derived from the email-keyed client_traffics.last_online
column, which remote-node syncs bump too, leaking remote-only clients onto
local inbounds.

Track online clients per node: the local panel's own xray clients under key
0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in
memory and independent of the shared last_online column) and each remote
node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode
endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope
online by node. The flat GetOnlineClients union is kept for client-centric and
total-count views (Clients page, dashboard, telegram).

Closes #4809
This commit is contained in:
MHSanaei
2026-06-02 18:33:21 +02:00
parent 6f6c7fc17a
commit 3af2da0142
12 changed files with 334 additions and 31 deletions

View File

@@ -55,6 +55,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByNode", a.onlinesByNode)
g.POST("/lastOnline", a.lastOnline)
}
@@ -397,6 +398,10 @@ func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) onlinesByNode(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)

View File

@@ -109,7 +109,10 @@ func (j *NodeTrafficSyncJob) Run() {
lastOnline = map[string]int64{}
}
j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
// Prune stale local-online entries (no local active emails to add here —
// only the local xray poll feeds those) so a stopped local xray's clients
// still age out between traffic polls.
j.inboundService.RefreshLocalOnlineClients(nil)
if !websocket.HasClients() {
return
@@ -121,6 +124,7 @@ func (j *NodeTrafficSyncJob) Run() {
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"lastOnlineMap": lastOnline,
})

View File

@@ -72,7 +72,17 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64)
}
j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
// Derive the local online set from this poll's per-email deltas rather
// than the shared last_online column, which remote-node syncs also bump
// and would otherwise make a client active only on a remote node appear
// online on local inbounds.
activeEmails := make([]string, 0, len(clientTraffics))
for _, ct := range clientTraffics {
if ct != nil && ct.Up+ct.Down > 0 {
activeEmails = append(activeEmails, ct.Email)
}
}
j.inboundService.RefreshLocalOnlineClients(activeEmails)
if !websocket.HasClients() {
return
@@ -86,6 +96,7 @@ func (j *XrayTrafficJob) Run() {
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"lastOnlineMap": lastOnlineMap,
})

View File

@@ -3259,6 +3259,13 @@ func (s *InboundService) GetOnlineClients() []string {
return p.GetOnlineClients()
}
func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
if p == nil {
return map[int][]string{}
}
return p.GetOnlineClientsByNode()
}
func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
if p != nil {
p.SetNodeOnlineClients(nodeID, emails)
@@ -3285,16 +3292,13 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil
}
func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
now := time.Now().UnixMilli()
newOnlineClients := make([]string, 0, len(lastOnlineMap))
for email, lastOnline := range lastOnlineMap {
if now-lastOnline < onlineGracePeriodMs {
newOnlineClients = append(newOnlineClients, email)
}
}
// RefreshLocalOnlineClients folds the emails active on this panel's own
// xray this poll into the local online set, applying the grace window and
// pruning stale entries. Pass nil to only prune. See xray.Process for why
// the local set is kept separate from the shared last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails []string) {
if p != nil {
p.SetOnlineClients(newOnlineClients)
p.RefreshLocalOnline(activeEmails, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}

View File

@@ -224,10 +224,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", inboundIDs).
Scan(&trafficRows).Error; err == nil {
online := make(map[string]struct{})
for _, email := range s.onlineEmails() {
online[email] = struct{}{}
}
onlineByNodeSet := s.onlineEmailsByNode()
depletedByNode := make(map[int]int)
onlineByNode := make(map[int]int)
for _, row := range trafficRows {
@@ -240,8 +237,12 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
if expired || exhausted || !row.Enable {
depletedByNode[nodeID]++
}
if _, ok := online[row.Email]; ok {
onlineByNode[nodeID]++
// Scope online by the node the inbound lives on: a client online
// on one node must not count as online on another.
if set, ok := onlineByNodeSet[nodeID]; ok {
if _, isOnline := set[row.Email]; isOnline {
onlineByNode[nodeID]++
}
}
}
for _, n := range nodes {
@@ -254,9 +255,18 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
return nodes, nil
}
func (s *NodeService) onlineEmails() []string {
func (s *NodeService) onlineEmailsByNode() map[int]map[string]struct{} {
svc := InboundService{}
return svc.GetOnlineClients()
byNode := svc.GetOnlineClientsByNode()
out := make(map[int]map[string]struct{}, len(byNode))
for nodeID, emails := range byNode {
set := make(map[string]struct{}, len(emails))
for _, email := range emails {
set[email] = struct{}{}
}
out[nodeID] = set
}
return out
}
func (s *NodeService) GetById(id int) (*model.Node, error) {