mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-04 19:39:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user