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) {