fix(traffic): show live Speed for AmneziaWG and MTProto inbounds/clients

The Speed column showed "--" for AmneziaWG (and MTProto, which has the
identical gap) even while cumulative traffic totals were correct.
XrayTrafficJob drives live speed by querying xray-core's own stats API
and broadcasting the delta over websocket -- but AmneziaWG/MTProto never
run inside xray-core's own runtime inbounds, so they're invisible to
that API. Their own jobs already compute the same per-poll delta shape
(that's what keeps cumulative totals correct) but never broadcast it.

Reusing the existing "traffics"/"clientTraffics" broadcast would have
two real bugs: the frontend's existing scope/replace logic would let
each side clobber the other's speed on its next unrelated tick, and the
websocket hub's per-message-type throttle is keyed only by message type,
not caller -- since both sidecar jobs run on identical "@every 10s"
grids registered milliseconds apart, one would silently lose almost
every broadcast if both protocols were ever configured together.

Fixed with a small unthrottled broadcast path (both sidecar jobs are
already self-rate-limited by their own cron cadence) and protocol-
namespaced wire keys, tracked in their own frontend state and merged
into the existing inboundSpeed/clientSpeed only at read time -- so every
existing consumer needs zero changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-26 18:27:23 +03:00
parent 0436ac641f
commit 1aa81428b8
10 changed files with 348 additions and 4 deletions
+5
View File
@@ -2,6 +2,7 @@ package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -84,5 +85,9 @@ func (j *AmneziaWGJob) Run() {
}
}
// Live speed: AmneziaWG never runs inside xray-core, so XrayTrafficJob's
// own 5s broadcast never mentions these tags. See sidecar_traffic.go.
broadcastSidecarTraffic(string(model.AmneziaWG), traffics, clientTraffics)
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
}
+8
View File
@@ -1,6 +1,7 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -77,5 +78,12 @@ func (j *MtprotoJob) Run() {
}
}
// Live speed: mtproto's mtg sidecar never runs inside xray-core, so
// XrayTrafficJob's own 5s broadcast never mentions these tags. traffics
// here already excludes routed-through-xray inbound tags (existing
// logic above, for cumulative-totals reasons) -- reused as-is, not
// recomputed. See sidecar_traffic.go.
broadcastSidecarTraffic(string(model.MTProto), traffics, clientTraffics)
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
}
+43
View File
@@ -0,0 +1,43 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// sidecarTrafficPayload builds the websocket broadcast map for one poll of a
// sidecar protocol's traffic delta. "Sidecar" means a protocol that never
// runs inside xray-core's own inbounds -- AmneziaWG's kernel tunnel and
// MTProto's mtg process are the only two today (both explicitly skipped from
// claiming an xray-core tag in GenXrayInboundConfig). XrayTrafficJob's own 5s
// broadcast (xray_traffic_job.go) carries "traffics"/"clientTraffics" for
// xray-native inbounds/clients and never mentions sidecar tags, so reusing
// those keys would let each side's broadcast clobber the other's speed on
// its very next, unrelated tick. protocol namespaces the keys instead:
// "amneziawg" produces "amneziawgTraffics"/"amneziawgClientTraffics",
// "mtproto" produces "mtprotoTraffics"/"mtprotoClientTraffics" -- distinct
// pairs per protocol, so the frontend tracks each independently and never
// has to reconcile two protocols within one map (see useInbounds.ts /
// useClients.ts).
func sidecarTrafficPayload(protocol string, traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) map[string]any {
return map[string]any{
protocol + "Traffics": traffics,
protocol + "ClientTraffics": clientTraffics,
}
}
// broadcastSidecarTraffic pushes this poll's live-speed snapshot for a
// sidecar protocol ("amneziawg" or "mtproto") over the websocket, so its
// inbound/client rows show a live Speed value the same way xray-native rows
// already do. Call this every tick -- even with empty slices -- so a peer
// that just went idle clears to no-speed on the frontend instead of sticking
// at its last nonzero reading; only the AddTraffic/RefreshLocalOnlineClients
// calls around it are conditioned on non-empty data, since cumulative-totals
// accounting doesn't need this signal. Uses BroadcastSidecarTraffic (not
// BroadcastTraffic) -- see its doc comment for why.
func broadcastSidecarTraffic(protocol string, traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
if !websocket.HasClients() {
return
}
websocket.BroadcastSidecarTraffic(sidecarTrafficPayload(protocol, traffics, clientTraffics))
}
+59
View File
@@ -0,0 +1,59 @@
package job
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSidecarTrafficPayload_UsesProtocolNamespacedKeys(t *testing.T) {
traffics := []*xray.Traffic{{IsInbound: true, Tag: "amneziawg-1", Up: 10, Down: 20}}
clientTraffics := []*xray.ClientTraffic{{Email: "a@b.c", Up: 10, Down: 20}}
payload := sidecarTrafficPayload("amneziawg", traffics, clientTraffics)
if _, ok := payload["amneziawgTraffics"]; !ok {
t.Fatal("expected amneziawgTraffics key")
}
if _, ok := payload["amneziawgClientTraffics"]; !ok {
t.Fatal("expected amneziawgClientTraffics key")
}
for _, wrongKey := range []string{"traffics", "clientTraffics", "mtprotoTraffics", "mtprotoClientTraffics"} {
if _, ok := payload[wrongKey]; ok {
t.Fatalf("payload must not contain %q -- it would collide with a different broadcast source", wrongKey)
}
}
}
func TestSidecarTrafficPayload_DistinctProtocolsNamespacedIndependently(t *testing.T) {
amneziawgPayload := sidecarTrafficPayload("amneziawg", nil, nil)
mtprotoPayload := sidecarTrafficPayload("mtproto", nil, nil)
if _, ok := amneziawgPayload["mtprotoTraffics"]; ok {
t.Fatal("amneziawg payload must not contain mtproto keys")
}
if _, ok := mtprotoPayload["amneziawgTraffics"]; ok {
t.Fatal("mtproto payload must not contain amneziawg keys")
}
}
func TestSidecarTrafficPayload_EmptyInputsStillProduceBothKeys(t *testing.T) {
// The frontend clears a peer's speed when its tag/email is absent from
// the payload's arrays -- but only if the key itself is present. If the
// key vanished entirely for an idle poll, idle-clearing would never
// trigger and the last nonzero speed would stick forever.
payload := sidecarTrafficPayload("amneziawg", nil, nil)
if _, ok := payload["amneziawgTraffics"]; !ok {
t.Fatal("expected amneziawgTraffics key present even for nil input")
}
if _, ok := payload["amneziawgClientTraffics"]; !ok {
t.Fatal("expected amneziawgClientTraffics key present even for nil input")
}
}
func TestBroadcastSidecarTraffic_NoOpWithoutHub(t *testing.T) {
// No global web server/hub is configured in this test binary, so
// websocket.HasClients() is false -- this must return without panicking.
broadcastSidecarTraffic("amneziawg", nil, nil)
}
+31
View File
@@ -288,6 +288,37 @@ func (h *Hub) Broadcast(messageType MessageType, payload any) {
h.enqueue(data)
}
// BroadcastUnthrottled behaves like Broadcast but skips the per-type rate
// limit in shouldThrottle. Use for message types whose callers are already
// self-limited by their own poll cadence (e.g. the AmneziaWG/MTProto sidecar
// traffic jobs, both "@every 10s" in internal/web/web.go), so a same-tick
// collision with another caller of the same MessageType (see
// throttledMessageTypes) never silently drops one side. robfig/cron's
// "@every" schedules are lastRun+interval grids anchored at AddJob time, not
// wall-clock-aligned, so two same-cadence jobs registered milliseconds apart
// stay in lockstep indefinitely -- with the throttled path, one of them
// would lose almost every tick, forever, not just occasionally.
func (h *Hub) BroadcastUnthrottled(messageType MessageType, payload any) {
if h == nil || payload == nil || h.GetClientCount() == 0 {
return
}
data, err := json.Marshal(Message{
Type: messageType,
Payload: payload,
Time: time.Now().UnixMilli(),
})
if err != nil {
logger.Error("WebSocket marshal failed:", err)
return
}
if len(data) > maxMessageSize {
logger.Debugf("WebSocket payload %d bytes exceeds limit, sending invalidate for %s", len(data), messageType)
h.broadcastInvalidate(messageType)
return
}
h.enqueue(data)
}
// broadcastInvalidate queues a lightweight signal telling clients to re-fetch
// the named data type via REST.
func (h *Hub) broadcastInvalidate(originalType MessageType) {
+71
View File
@@ -176,6 +176,77 @@ func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
}
}
func TestHub_BroadcastUnthrottled_DeliversDespiteHotThrottleWindow(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
c := NewClient("c1")
h.Register(c)
waitClientCount(t, h, 1)
// Consume the throttle window for MessageTypeTraffic, as
// TestHub_ShouldThrottle already does directly.
if h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("first call should not throttle")
}
if !h.shouldThrottle(MessageTypeTraffic) {
t.Fatal("second immediate call should throttle -- window not hot as expected")
}
h.BroadcastUnthrottled(MessageTypeTraffic, map[string]string{"k": "v"})
select {
case raw := <-c.Send:
var m Message
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
}
if m.Type != MessageTypeTraffic {
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeTraffic)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("BroadcastUnthrottled should deliver even with a hot throttle window")
}
}
func TestHub_BroadcastUnthrottled_DropsWhenNoClients(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
h.BroadcastUnthrottled(MessageTypeTraffic, "payload")
select {
case <-h.broadcast:
t.Fatal("BroadcastUnthrottled should drop when client count is zero")
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_BroadcastUnthrottled_DropsNilPayload(t *testing.T) {
h := NewHub()
defer h.Stop()
go h.Run()
c := NewClient("c1")
h.Register(c)
waitClientCount(t, h, 1)
h.BroadcastUnthrottled(MessageTypeTraffic, nil)
select {
case <-c.Send:
t.Fatal("nil payload should be dropped, not delivered")
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_BroadcastUnthrottled_NilReceiverDoesNotPanic(t *testing.T) {
var h *Hub
h.BroadcastUnthrottled(MessageTypeTraffic, "anything")
}
func TestTrySend_SucceedsWithRoom(t *testing.T) {
c := &Client{ID: "c", Send: make(chan []byte, 1)}
if !trySend(c, []byte("hi")) {
+13
View File
@@ -45,6 +45,19 @@ func BroadcastTraffic(traffic any) {
}
}
// BroadcastSidecarTraffic broadcasts an AmneziaWG/MTProto traffic delta under
// the same "traffic" message type BroadcastTraffic uses, but bypasses the
// hub's per-type throttle (see Hub.BroadcastUnthrottled) so the two sidecar
// jobs' independent ~10s broadcasts can never starve each other. The payload
// carries protocol-namespaced keys (see internal/web/job/sidecar_traffic.go),
// so no new frontend message-type wiring is needed -- applyTrafficEvent
// already receives every "traffic" message.
func BroadcastSidecarTraffic(traffic any) {
if hub := GetHub(); hub != nil {
hub.BroadcastUnthrottled(MessageTypeTraffic, traffic)
}
}
// BroadcastClientStats broadcasts absolute per-client traffic counters. Small
// installs send the complete row set each cycle (payload key snapshot=true);
// above the traffic job's snapshot threshold only the rows active in the