fix(online): scope per-inbound online to inbounds that carried traffic

Multi-inbound clients showed online on every inbound they were attached to. Xray's user-level traffic stat aggregates across all inbounds a client belongs to, so the email signal alone can't say which inbound was used.

Pair it with the inbound-level traffic signal under the same 20s grace and gate the per-inbound rollup on it: a client only shows online on inbounds that actually moved bytes this window. Remote nodes report no per-inbound activity and stay ungated (no regression). Adds GetActiveInboundsByNode, the activeInbounds WS field and POST /panel/api/clients/activeInbounds.

Fixes #4859
This commit is contained in:
MHSanaei
2026-06-03 16:19:00 +02:00
parent 5fb18b8819
commit ef8882a5c0
11 changed files with 224 additions and 30 deletions
+5
View File
@@ -56,6 +56,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByNode", a.onlinesByNode)
g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline)
}
@@ -402,6 +403,10 @@ func (a *ClientController) onlinesByNode(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
}
func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
+8 -7
View File
@@ -109,10 +109,10 @@ func (j *NodeTrafficSyncJob) Run() {
lastOnline = map[string]int64{}
}
// 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)
// Prune stale local-online entries (no local active emails or inbound tags
// to add here — only the local xray poll feeds those) so a stopped local
// xray's clients and inbounds still age out between traffic polls.
j.inboundService.RefreshLocalOnlineClients(nil, nil)
if !websocket.HasClients() {
return
@@ -123,9 +123,10 @@ func (j *NodeTrafficSyncJob) Run() {
online = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"lastOnlineMap": lastOnline,
"onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"lastOnlineMap": lastOnline,
})
clientStats := map[string]any{}
+13 -1
View File
@@ -82,7 +82,18 @@ func (j *XrayTrafficJob) Run() {
activeEmails = append(activeEmails, ct.Email)
}
}
j.inboundService.RefreshLocalOnlineClients(activeEmails)
// Pair the email signal with the inbound tags that moved bytes this poll.
// Xray's user>>>email counter aggregates across every inbound a client is
// attached to, so an online email alone can't say which inbound it used —
// gating the per-inbound view on these tags keeps a multi-inbound client
// off inbounds that saw no traffic. See issue #4859.
activeInboundTags := make([]string, 0, len(traffics))
for _, tr := range traffics {
if tr != nil && tr.IsInbound && tr.Up+tr.Down > 0 {
activeInboundTags = append(activeInboundTags, tr.Tag)
}
}
j.inboundService.RefreshLocalOnlineClients(activeEmails, activeInboundTags)
if !websocket.HasClients() {
return
@@ -97,6 +108,7 @@ func (j *XrayTrafficJob) Run() {
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"lastOnlineMap": lastOnlineMap,
})
+14 -6
View File
@@ -3339,6 +3339,13 @@ func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
return p.GetOnlineClientsByNode()
}
func (s *InboundService) GetActiveInboundsByNode() map[int][]string {
if p == nil {
return map[int][]string{}
}
return p.GetActiveInboundsByNode()
}
func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
if p != nil {
p.SetNodeOnlineClients(nodeID, emails)
@@ -3365,13 +3372,14 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil
}
// 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) {
// RefreshLocalOnlineClients folds the emails and inbound tags active on this
// panel's own xray this poll into the local online/active sets, applying the
// grace window and pruning stale entries. Pass nil to only prune. See
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if p != nil {
p.RefreshLocalOnline(activeEmails, time.Now().UnixMilli(), onlineGracePeriodMs)
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}