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
+48 -2
View File
@@ -32,7 +32,7 @@ import {
type BulkDetachResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
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';
// One row sent to POST /clients/:email/externalLinks.
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
@@ -271,6 +271,18 @@ export function useClients() {
// the server's authoritative total for the headline count.
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
// AmneziaWG/MTProto run entirely outside xray-core, so their live speed
// never arrives via the xray-native clientTraffics broadcast below -- each
// sidecar job broadcasts its own ~10s snapshot under protocol-named keys
// instead (see internal/web/job/sidecar_traffic.go). Kept as two
// independent, protocol-only maps rather than folding into clientSpeed: a
// client's email can't move between protocols, but this hook (unlike
// useInbounds.ts) has no cheap per-email protocol lookup, so a shared map's
// full-replace could only be made safe by tagging ownership per entry --
// two plain maps are simpler and just as correct, since each is written by
// exactly one job.
const [amneziawgClientSpeed, setAmneziawgClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const [mtprotoClientSpeed, setMtprotoClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const summary = useMemo<ClientsSummary>(() => {
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
if (allClientStats.length === 0) return serverSummary;
@@ -553,6 +565,8 @@ export function useClients() {
const p = payload as {
onlineClients?: string[];
clientTraffics?: { email: string; up: number; down: number }[];
amneziawgClientTraffics?: { email: string; up: number; down: number }[];
mtprotoClientTraffics?: { email: string; up: number; down: number }[];
};
if (Array.isArray(p.onlineClients)) {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
@@ -568,6 +582,28 @@ export function useClients() {
}
setClientSpeed(next);
}
// Mirrors the block above exactly, but as two independent, protocol-only
// maps (see the amneziawgClientSpeed/mtprotoClientSpeed declaration).
const applySidecarClientTraffics = (
traffics: { email: string; up: number; down: number }[],
setSpeed: (next: Record<string, ClientSpeedEntry>) => void,
) => {
const next: Record<string, ClientSpeedEntry> = {};
for (const ct of traffics) {
if (!ct || !ct.email) continue;
next[ct.email] = {
up: (ct.up || 0) / SIDECAR_TRAFFIC_POLL_INTERVAL_S,
down: (ct.down || 0) / SIDECAR_TRAFFIC_POLL_INTERVAL_S,
};
}
setSpeed(next);
};
if (Array.isArray(p.amneziawgClientTraffics)) {
applySidecarClientTraffics(p.amneziawgClientTraffics, setAmneziawgClientSpeed);
}
if (Array.isArray(p.mtprotoClientTraffics)) {
applySidecarClientTraffics(p.mtprotoClientTraffics, setMtprotoClientSpeed);
}
}, [queryClient]);
const applyClientStatsEvent = useCallback((payload: unknown) => {
@@ -606,6 +642,16 @@ export function useClients() {
queryRef.current = query;
}, [query]);
// AmneziaWG/MTProto speed lives in its own state (see above) and is merged
// in here only for consumers -- a client's email is always exactly one
// protocol, so this can never overwrite a real xray-native entry.
const clientSpeedOut = useMemo(() => {
if (Object.keys(amneziawgClientSpeed).length === 0 && Object.keys(mtprotoClientSpeed).length === 0) {
return clientSpeed;
}
return { ...clientSpeed, ...amneziawgClientSpeed, ...mtprotoClientSpeed };
}, [clientSpeed, amneziawgClientSpeed, mtprotoClientSpeed]);
return {
clients,
total,
@@ -650,7 +696,7 @@ export function useClients() {
exportClients,
importClients,
setEnable,
clientSpeed,
clientSpeed: clientSpeedOut,
applyTrafficEvent,
applyClientStatsEvent,
};
@@ -1 +1,5 @@
export const TRAFFIC_POLL_INTERVAL_S = 5;
// Mirrors cadenceAmneziaWG / cadenceMtproto in internal/web/web.go (both
// "@every 10s"). If either cadence constant changes, update this to match.
export const SIDECAR_TRAFFIC_POLL_INTERVAL_S = 10;
+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,