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
+31 -5
View File
@@ -24,7 +24,7 @@ func assertSameSet(t *testing.T, label string, got, want []string) {
// client online on one node must not be reported online on any other node.
func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
p := newOnlineTestProcess()
p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
p.SetNodeOnlineClients(5, []string{"user3"})
@@ -63,7 +63,7 @@ func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
// client-centric / total-count views) still merges every node and dedupes.
func TestGetOnlineClientsUnionDedupes(t *testing.T) {
p := newOnlineTestProcess()
p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
@@ -76,18 +76,18 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
p := newOnlineTestProcess()
const grace = 20000
p.RefreshLocalOnline([]string{"user1"}, 1000, grace)
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
t.Fatalf("user1 should be online right after activity, got %v", got)
}
p.RefreshLocalOnline([]string{"user2"}, 11000, grace)
p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
got := p.GetOnlineClientsByNode()[localNodeKey]
if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
t.Fatalf("both within grace window, got %v", got)
}
p.RefreshLocalOnline(nil, 22000, grace)
p.RefreshLocalOnline(nil, nil, 22000, grace)
got = p.GetOnlineClientsByNode()[localNodeKey]
if slices.Contains(got, "user1") {
t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
@@ -97,6 +97,32 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
}
}
// TestGetActiveInboundsByNodeTracksGraceWindow pins the fix for issue #4859: a
// multi-inbound client must only count as online on inbounds that actually
// carried traffic. The active-inbound signal honours the same grace window as
// the online-email signal, and only this panel's tags report under key 0.
func TestGetActiveInboundsByNodeTracksGraceWindow(t *testing.T) {
p := newOnlineTestProcess()
const grace = 20000
p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
got := p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "active after first poll", got, []string{"inbound-a"})
p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
got = p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "both within grace", got, []string{"inbound-a", "inbound-b"})
p.RefreshLocalOnline(nil, nil, 22000, grace)
got = p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "inbound-a (idle 21s, past grace) aged out, inbound-b kept", got, []string{"inbound-b"})
p.RefreshLocalOnline(nil, nil, 40000, grace)
if _, ok := p.GetActiveInboundsByNode()[localNodeKey]; ok {
t.Errorf("all inbounds idle past grace, key 0 should be absent: %v", p.GetActiveInboundsByNode())
}
}
// TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
// clients must disappear from the per-node map immediately.
func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
+56 -7
View File
@@ -135,6 +135,13 @@ type process struct {
// snapshots — so a client connected solely to a remote node is not
// reported online on local inbounds.
onlineClients []string
// localActiveInbounds is the set of THIS panel's inbound tags that
// carried traffic within the same grace window. Xray's user>>>email
// stat aggregates across every inbound a client is attached to, so an
// online email alone can't say which inbound it actually used. Pairing
// it with the inbound>>>tag stat lets the per-inbound view drop a
// multi-inbound client from inbounds that saw no traffic this window.
localActiveInbounds []string
// localLastOnline records, per email, the last time this panel's own
// xray reported traffic for it. RefreshLocalOnline rebuilds
// onlineClients from this map each tick, keeping the local online set
@@ -142,6 +149,12 @@ type process struct {
// column is bumped by remote-node syncs too and would otherwise leak
// remote-only clients into the local set.
localLastOnline map[string]int64
// localInboundLastActive mirrors localLastOnline for inbound tags: the
// last tick this panel's xray reported traffic through each tag.
// Rebuilt into localActiveInbounds under the same grace window so the
// two signals stay aligned — an email within grace always has the
// inbound it used within grace too.
localInboundLastActive map[string]int64
// nodeOnlineClients holds the online-emails list reported by each
// remote node, keyed by node id. NodeTrafficSyncJob populates entries
// per cron tick and clears them when a node's probe fails. The mutex
@@ -296,13 +309,33 @@ func (p *Process) GetOnlineClientsByNode() map[int][]string {
return out
}
// RefreshLocalOnline records that each email in activeEmails had local xray
// traffic at now, then rebuilds onlineClients from every email seen within
// graceMs and prunes entries older than that. Called by the local
// XrayTrafficJob after each xray gRPC stats poll. Pass a nil/empty
// activeEmails to only prune — NodeTrafficSyncJob does this so a stopped
// local xray's clients still age out between local traffic polls.
func (p *Process) RefreshLocalOnline(activeEmails []string, now, graceMs int64) {
// GetActiveInboundsByNode returns the inbound tags that carried traffic within
// the grace window, grouped by node. Only this panel's own xray reports
// per-inbound activity (under localNodeKey); remote-node snapshots don't carry
// it, so their nodes are simply absent — the per-inbound view reads "node
// missing" as "don't gate" and falls back to the email-only signal there.
// Empty groups are omitted, mirroring GetOnlineClientsByNode.
func (p *Process) GetActiveInboundsByNode() map[int][]string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.localActiveInbounds) == 0 {
return map[int][]string{}
}
out := make(map[int][]string, 1)
local := make([]string, len(p.localActiveInbounds))
copy(local, p.localActiveInbounds)
out[localNodeKey] = local
return out
}
// RefreshLocalOnline records that each email in activeEmails and each tag in
// activeInboundTags had local xray traffic at now, then rebuilds onlineClients
// and localActiveInbounds from every entry seen within graceMs, pruning older
// ones. Called by the local XrayTrafficJob after each xray gRPC stats poll.
// Pass nil/empty slices to only prune — NodeTrafficSyncJob does this so a
// stopped local xray's clients and inbounds still age out between local polls.
func (p *Process) RefreshLocalOnline(activeEmails, activeInboundTags []string, now, graceMs int64) {
p.onlineMu.Lock()
defer p.onlineMu.Unlock()
if p.localLastOnline == nil {
@@ -320,6 +353,22 @@ func (p *Process) RefreshLocalOnline(activeEmails []string, now, graceMs int64)
}
}
p.onlineClients = online
if p.localInboundLastActive == nil {
p.localInboundLastActive = make(map[string]int64, len(activeInboundTags))
}
for _, tag := range activeInboundTags {
p.localInboundLastActive[tag] = now
}
activeInbounds := make([]string, 0, len(p.localInboundLastActive))
for tag, ts := range p.localInboundLastActive {
if now-ts < graceMs {
activeInbounds = append(activeInbounds, tag)
} else {
delete(p.localInboundLastActive, tag)
}
}
p.localActiveInbounds = activeInbounds
}
// SetNodeOnlineClients records the online-emails set for one remote