mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-26 09:12:10 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user