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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
Abdalrahman
2026-05-15 12:41:29 +03:00
committed by GitHub
parent 5cf8a08540
commit 78f1719c6d
3 changed files with 38 additions and 18 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()