feat(ui): show per-inbound live speed (#5261)

* feat(utils): add speedFormat utility and tests

* feat(inbounds): add InboundSpeedEntry type

* feat(inbounds): add speed column to inbound list

* feat(inbounds): show speed in inbound stats modal

* feat(inbounds): compute inbound speed from traffic deltas

* feat(inbounds): wire inbound speed through page

* feat(i18n): add speed translation for all locales

* refactor(inbounds): dedupe live-speed UI and harden formatting

Extract a shared InboundSpeedTag component and isActiveSpeed guard used by the speed column and stats modal, unify InboundSpeedEntry into a single type, and route speedFormat through sizeFormat.

Also guard sizeFormat against non-finite input (no more "NaN PB/s") and clear stale per-inbound speeds when a traffic poll returns no deltas.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Nikan Zeyaei
2026-06-15 00:09:40 +03:30
committed by GitHub
parent 1c75034957
commit f4bbaf40f0
22 changed files with 198 additions and 5 deletions
+43 -1
View File
@@ -12,6 +12,8 @@ import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
import type { InboundSpeedEntry } from './list/types';
export interface SubSettings {
enable: boolean;
subTitle: string;
@@ -26,6 +28,17 @@ export interface SubSettings {
type DBInboundInstance = InstanceType<typeof DBInbound>;
// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
// deltas accumulated over this window, so dividing by it yields bytes/sec.
const TRAFFIC_POLL_INTERVAL_S = 5;
interface TrafficDelta {
Tag: string;
Up: number;
Down: number;
IsInbound?: boolean;
}
interface ClientRollup {
clients: number;
active: string[];
@@ -179,6 +192,8 @@ export function useInbounds() {
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
@@ -383,7 +398,13 @@ export function useInbounds() {
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
const p = payload as {
traffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
lastOnlineMap?: Record<string, number>;
};
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
@@ -397,6 +418,26 @@ export function useInbounds() {
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Full-replace each poll so idle inbounds (and an empty array after an
// Xray stat reset) clear their speed instead of showing a stale value.
if (Array.isArray(p.traffics)) {
const byTag = new Map<string, TrafficDelta>();
for (const tr of p.traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
const nextSpeed: Record<number, InboundSpeedEntry> = {};
for (const ib of dbInboundsRef.current) {
const delta = byTag.get(ib.tag);
if (!delta) continue;
nextSpeed[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
}
setInboundSpeed(nextSpeed);
}
rebuildClientCount();
},
[rebuildClientCount],
@@ -481,6 +522,7 @@ export function useInbounds() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
statsVersion,
totals,
expireDiff,