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
+66 -2
View File
@@ -13,7 +13,7 @@ import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
import type { InboundSpeedEntry } from './list/types';
import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
import { TRAFFIC_POLL_INTERVAL_S, SIDECAR_TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
export interface SubSettings {
enable: boolean;
@@ -206,6 +206,16 @@ export function useInbounds() {
inboundSpeedCache = { at: Date.now(), data: inboundSpeed };
}, [inboundSpeed]);
// AmneziaWG/MTProto run entirely outside xray-core, so their live speed
// never arrives via the xray-native traffics/nodeTraffics broadcast above
// -- each sidecar job broadcasts its own ~10s snapshot under protocol-named
// keys instead (see internal/web/job/sidecar_traffic.go). Tracked in their
// own state, independent from inboundSpeed, and merged in only at read
// time (inboundSpeedOut below) -- a given inbound is exactly one protocol,
// so the maps never need to agree on the same id.
const [amneziawgInboundSpeed, setAmneziawgInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
const [mtprotoInboundSpeed, setMtprotoInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
@@ -413,6 +423,8 @@ export function useInbounds() {
const p = payload as {
traffics?: TrafficDelta[];
nodeTraffics?: TrafficDelta[];
amneziawgTraffics?: TrafficDelta[];
mtprotoTraffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
@@ -465,6 +477,48 @@ export function useInbounds() {
};
if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
// AmneziaWG/MTProto never appear in traffics/nodeTraffics above (they
// don't run inside xray-core), so each broadcasts its own ~10s
// snapshot under its own protocol-named keys instead (see
// internal/web/job/sidecar_traffic.go). Sidecar inbounds are always
// local-only (isNodeEligibleProtocol excludes both server-side), so
// no local/node scope split is needed here.
const applySidecarInboundTraffics = (
traffics: TrafficDelta[],
protocol: string,
setSpeed: (updater: (prev: Record<number, InboundSpeedEntry>) => Record<number, InboundSpeedEntry>) => void,
) => {
const byTag = new Map<string, TrafficDelta>();
for (const tr of traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
setSpeed((prev) => {
const next = { ...prev };
for (const ib of dbInboundsRef.current) {
if (ib.protocol !== protocol) continue;
const delta = byTag.get(ib.tag);
if (delta) {
next[ib.id] = {
up: (delta.Up || 0) / SIDECAR_TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / SIDECAR_TRAFFIC_POLL_INTERVAL_S,
};
} else {
delete next[ib.id];
}
}
return next;
});
};
if (Array.isArray(p.amneziawgTraffics)) {
applySidecarInboundTraffics(p.amneziawgTraffics, Protocols.AMNEZIAWG, setAmneziawgInboundSpeed);
}
if (Array.isArray(p.mtprotoTraffics)) {
applySidecarInboundTraffics(p.mtprotoTraffics, Protocols.MTPROTO, setMtprotoInboundSpeed);
}
rebuildClientCount();
},
[rebuildClientCount],
@@ -542,6 +596,16 @@ export function useInbounds() {
return { up, down };
}, [dbInbounds]);
// AmneziaWG/MTProto speed lives in its own state (see above) and is merged
// in here only for consumers -- a given inbound is exactly one protocol,
// so this can never overwrite a real xray-native entry.
const inboundSpeedOut = useMemo(() => {
if (Object.keys(amneziawgInboundSpeed).length === 0 && Object.keys(mtprotoInboundSpeed).length === 0) {
return inboundSpeed;
}
return { ...inboundSpeed, ...amneziawgInboundSpeed, ...mtprotoInboundSpeed };
}, [inboundSpeed, amneziawgInboundSpeed, mtprotoInboundSpeed]);
return {
fetched,
fetchError,
@@ -549,7 +613,7 @@ export function useInbounds() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
inboundSpeed: inboundSpeedOut,
statsVersion,
totals,
expireDiff,