Browse Source

fix: prevent online clients from randomly disappearing from panel UI (#4387)

* fix: prevent online clients from randomly disappearing from panel UI

Online status was determined solely by whether a client transferred
bytes in the current 5-second polling window. The online list was
completely replaced each cycle, so idle-but-connected clients with no
traffic delta in that window were dropped from the UI.

Now online status is computed from lastOnline DB timestamps with a
5-second grace period via RefreshOnlineClientsFromMap(), so clients
remain visible across idle polling windows.

Closes #4384

* fix: extend online client grace period to survive idle poll cycles

The 5s grace period equalled the traffic-poll interval, so a client
whose Xray stats reported a zero delta for one cycle was still dropped
on the very next tick. Bump to 20s (~4 polls) so idle-but-connected
sessions stay visible across momentary counter gaps without lingering
long after a real disconnect.

Refs #4384

---------

Co-authored-by: MHSanaei <[email protected]>
Abdalrahman 14 hours ago
parent
commit
78f1719c6d
3 changed files with 38 additions and 18 deletions
  1. 7 4
      web/job/node_traffic_sync_job.go
  2. 11 4
      web/job/xray_traffic_job.go
  3. 20 10
      web/service/inbound.go

+ 7 - 4
web/job/node_traffic_sync_job.go

@@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
 		return
 	}
 
-	online := j.inboundService.GetOnlineClients()
-	if online == nil {
-		online = []string{}
-	}
 	lastOnline, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 		logger.Warning("node traffic sync: get last-online failed:", err)
@@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
 	if lastOnline == nil {
 		lastOnline = map[string]int64{}
 	}
+
+	j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
+
+	online := j.inboundService.GetOnlineClients()
+	if online == nil {
+		online = []string{}
+	}
 	websocket.BroadcastTraffic(map[string]any{
 		"onlineClients": online,
 		"lastOnlineMap": lastOnline,

+ 11 - 4
web/job/xray_traffic_job.go

@@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
 	// a missing/null onlineClients field as "no update", so without this the
 	// "everyone went offline" transition was silently dropped — stale online
 	// users lingered in the list and the online filter kept showing them.
-	onlineClients := j.inboundService.GetOnlineClients()
-	if onlineClients == nil {
-		onlineClients = []string{}
-	}
 	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
 	if err != nil {
 		logger.Warning("get clients last online failed:", err)
@@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
 	if lastOnlineMap == nil {
 		lastOnlineMap = make(map[string]int64)
 	}
+
+	// Determine online clients from lastOnline timestamps with a 5-second
+	// grace period instead of just the current 5-second traffic poll. This
+	// prevents idle-but-connected clients from randomly disappearing from
+	// the UI between polling windows.
+	j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
+
+	onlineClients := j.inboundService.GetOnlineClients()
+	if onlineClients == nil {
+		onlineClients = []string{}
+	}
 	websocket.BroadcastTraffic(map[string]any{
 		"traffics":       traffics,
 		"clientTraffics": clientTraffics,

+ 20 - 10
web/service/inbound.go

@@ -1539,6 +1539,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 
 const resetGracePeriodMs int64 = 30000
 
+// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
+// Xray's stats counters often report a zero delta for an active session across
+// a single poll, so a 5s grace would still drop the client on the next tick.
+// ~4 polls of slack keeps idle-but-connected clients visible without lingering
+// long after a real disconnect.
+const onlineGracePeriodMs int64 = 20000
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
@@ -1880,15 +1887,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
 
 func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
 	if len(traffics) == 0 {
-		// Empty onlineUsers
-		if p != nil {
-			p.SetOnlineClients(make([]string, 0))
-		}
 		return nil
 	}
 
-	onlineClients := make([]string, 0)
-
 	emails := make([]string, 0, len(traffics))
 	for _, traffic := range traffics {
 		emails = append(emails, traffic.Email)
@@ -1931,14 +1932,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		dbClientTraffics[dbTraffic_index].Down += t.Down
 		dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
 		if t.Up+t.Down > 0 {
-			onlineClients = append(onlineClients, t.Email)
 			dbClientTraffics[dbTraffic_index].LastOnline = now
 		}
 	}
 
-	// Set onlineUsers
-	p.SetOnlineClients(onlineClients)
-
 	err = tx.Save(dbClientTraffics).Error
 	if err != nil {
 		logger.Warning("AddClientTraffic update data ", err)
@@ -3764,6 +3761,19 @@ 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)
+		}
+	}
+	if p != nil {
+		p.SetOnlineClients(newOnlineClients)
+	}
+}
+
 func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
 	db := database.GetDB()