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
@@ -82,6 +82,7 @@ export default function InboundsPage() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
totals,
expireDiff,
trafficDiff,
@@ -620,6 +621,7 @@ export default function InboundsPage() {
clientCount={clientCount}
onlineClients={onlineClients}
lastOnlineMap={lastOnlineMap}
inboundSpeed={inboundSpeed}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
pageSize={pageSize}
@@ -36,6 +36,7 @@ export default function InboundList({
dbInbounds,
clientCount,
lastOnlineMap: _lastOnlineMap,
inboundSpeed,
expireDiff,
trafficDiff,
pageSize,
@@ -124,6 +125,7 @@ export default function InboundList({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -271,6 +273,7 @@ export default function InboundList({
hasActiveNode={hasActiveNode}
nodesById={nodesById}
clientCount={clientCount}
inboundSpeed={inboundSpeed}
trafficDiff={trafficDiff}
expireDiff={expireDiff}
onClose={() => setStatsRecord(null)}
@@ -0,0 +1,39 @@
import { Tag, Tooltip } from 'antd';
import { SizeFormatter } from '@/utils';
import type { InboundSpeedEntry } from './types';
// True when an inbound has live throughput worth showing.
export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedEntry {
return !!speed && (speed.up > 0 || speed.down > 0);
}
interface InboundSpeedTagProps {
speed: InboundSpeedEntry;
withTooltip?: boolean;
}
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
const tag = (
<Tag color="blue">
{SizeFormatter.speedFormat(speed.up)}
{' / '}
{SizeFormatter.speedFormat(speed.down)}
</Tag>
);
if (!withTooltip) return tag;
return (
<Tooltip
title={(
<div>
<div> {SizeFormatter.speedFormat(speed.up)}</div>
<div> {SizeFormatter.speedFormat(speed.down)}</div>
</div>
)}
>
{tag}
</Tooltip>
);
}
@@ -13,7 +13,8 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord } from './types';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry } from './types';
interface InboundStatsModalProps {
open: boolean;
@@ -21,6 +22,7 @@ interface InboundStatsModalProps {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
trafficDiff: number;
expireDiff: number;
onClose: () => void;
@@ -32,6 +34,7 @@ export default function InboundStatsModal({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
trafficDiff,
expireDiff,
onClose,
@@ -109,6 +112,16 @@ export default function InboundStatsModal({
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</div>
{(() => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) return null;
return (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.speed')}</span>
<InboundSpeedTag speed={speed} />
</div>
);
})()}
{clientCount[record.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
@@ -44,6 +44,11 @@ export interface ClientCountEntry {
online: string[];
}
export interface InboundSpeedEntry {
up: number;
down: number;
}
export type RowAction =
| 'edit'
| 'showInfo'
@@ -63,6 +68,7 @@ export interface InboundListProps {
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
inboundSpeed: Record<number, InboundSpeedEntry>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
@@ -9,6 +9,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { RowActionsCell } from './RowActions';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
readStreamHints,
networkLabel,
@@ -17,7 +18,7 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
@@ -25,6 +26,7 @@ interface UseInboundColumnsParams {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean;
expireDiff: number;
trafficDiff: number;
@@ -38,6 +40,7 @@ export function useInboundColumns({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -262,6 +265,19 @@ export function useInboundColumns({
</Popover>
),
},
{
title: t('pages.inbounds.speed'),
key: 'speed',
align: 'center',
width: 90,
render: (_, record) => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) {
return <Tag color='default'></Tag>;
}
return <InboundSpeedTag speed={speed} withTooltip />;
},
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
@@ -283,5 +299,5 @@ export function useInboundColumns({
);
return cols;
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}
+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,
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { SizeFormatter } from '@/utils';
describe('SizeFormatter.sizeFormat', () => {
it('formats zero and negative values', () => {
expect(SizeFormatter.sizeFormat(0)).toBe('0 B');
expect(SizeFormatter.sizeFormat(-1)).toBe('0 B');
expect(SizeFormatter.sizeFormat(null)).toBe('0 B');
expect(SizeFormatter.sizeFormat(undefined)).toBe('0 B');
});
it('formats bytes', () => {
expect(SizeFormatter.sizeFormat(512)).toBe('512 B');
});
it('formats kilobytes', () => {
expect(SizeFormatter.sizeFormat(1536)).toBe('1.50 KB');
});
});
describe('SizeFormatter.speedFormat', () => {
it('formats zero and negative values', () => {
expect(SizeFormatter.speedFormat(0)).toBe('0 B/s');
expect(SizeFormatter.speedFormat(-1)).toBe('0 B/s');
expect(SizeFormatter.speedFormat(null)).toBe('0 B/s');
expect(SizeFormatter.speedFormat(undefined)).toBe('0 B/s');
});
it('formats non-finite values as zero', () => {
expect(SizeFormatter.speedFormat(NaN)).toBe('0 B/s');
expect(SizeFormatter.speedFormat(Infinity)).toBe('0 B/s');
expect(SizeFormatter.sizeFormat(NaN)).toBe('0 B');
expect(SizeFormatter.sizeFormat(Infinity)).toBe('0 B');
});
it('formats bytes per second', () => {
expect(SizeFormatter.speedFormat(512)).toBe('512 B/s');
expect(SizeFormatter.speedFormat(1023)).toBe('1023 B/s');
});
it('formats kilobytes per second', () => {
expect(SizeFormatter.speedFormat(1024)).toBe('1.00 KB/s');
expect(SizeFormatter.speedFormat(1536)).toBe('1.50 KB/s');
});
it('formats megabytes per second', () => {
expect(SizeFormatter.speedFormat(1024 * 1024)).toBe('1.00 MB/s');
expect(SizeFormatter.speedFormat(2.5 * 1024 * 1024)).toBe('2.50 MB/s');
});
it('formats gigabytes per second', () => {
expect(SizeFormatter.speedFormat(1024 * 1024 * 1024)).toBe('1.00 GB/s');
});
});
+6 -1
View File
@@ -646,7 +646,7 @@ export class SizeFormatter {
static readonly ONE_PB = SizeFormatter.ONE_TB * 1024;
static sizeFormat(size: number | null | undefined): string {
if (size == null || size <= 0) return '0 B';
if (size == null || !Number.isFinite(size) || size <= 0) return '0 B';
if (size < SizeFormatter.ONE_KB) return size.toFixed(0) + ' B';
if (size < SizeFormatter.ONE_MB) return (size / SizeFormatter.ONE_KB).toFixed(2) + ' KB';
if (size < SizeFormatter.ONE_GB) return (size / SizeFormatter.ONE_MB).toFixed(2) + ' MB';
@@ -654,6 +654,11 @@ export class SizeFormatter {
if (size < SizeFormatter.ONE_PB) return (size / SizeFormatter.ONE_TB).toFixed(2) + ' TB';
return (size / SizeFormatter.ONE_PB).toFixed(2) + ' PB';
}
// Same unit ladder as sizeFormat, expressed per-second.
static speedFormat(bps: number | null | undefined): string {
return SizeFormatter.sizeFormat(bps) + '/s';
}
}
export class CPUFormatter {
+1
View File
@@ -289,6 +289,7 @@
"port": "المنفذ",
"portMap": "تعيين المنفذ",
"traffic": "حركة المرور",
"speed": "السرعة",
"details": "تفاصيل",
"transportConfig": "النقل",
"expireDate": "المدة",
+1
View File
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Port Mapping",
"traffic": "Traffic",
"speed": "Speed",
"details": "Details",
"transportConfig": "Transport",
"expireDate": "Duration",
+1
View File
@@ -289,6 +289,7 @@
"port": "Puerto",
"portMap": "Asignación de puertos",
"traffic": "Tráfico",
"speed": "Velocidad",
"details": "Detalles",
"transportConfig": "Transporte",
"expireDate": "Fecha de Expiración",
+1
View File
@@ -289,6 +289,7 @@
"port": "پورت",
"portMap": "نگاشت پورت",
"traffic": "ترافیک",
"speed": "سرعت",
"details": "توضیحات",
"transportConfig": "انتقال",
"expireDate": "مدت زمان",
+1
View File
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Pemetaan port",
"traffic": "Trafik",
"speed": "Kecepatan",
"details": "Rincian",
"transportConfig": "Transport",
"expireDate": "Durasi",
+1
View File
@@ -289,6 +289,7 @@
"port": "ポート",
"portMap": "ポートマッピング",
"traffic": "トラフィック",
"speed": "速度",
"details": "詳細情報",
"transportConfig": "トランスポート",
"expireDate": "有効期限",
+1
View File
@@ -289,6 +289,7 @@
"port": "Porta",
"portMap": "Mapeamento de portas",
"traffic": "Tráfego",
"speed": "Velocidade",
"details": "Detalhes",
"transportConfig": "Transporte",
"expireDate": "Duração",
+1
View File
@@ -289,6 +289,7 @@
"port": "Порт",
"portMap": "Сопоставление портов",
"traffic": "Трафик",
"speed": "Скорость",
"details": "Подробнее",
"transportConfig": "Транспорт",
"expireDate": "Дата окончания",
+1
View File
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Port Eşlemesi",
"traffic": "Trafik",
"speed": "Hız",
"details": "Detaylar",
"transportConfig": "Aktarım",
"expireDate": "Süre",
+1
View File
@@ -289,6 +289,7 @@
"port": "Порт",
"portMap": "Відображення портів",
"traffic": "Трафік",
"speed": "Швидкість",
"details": "Деталі",
"transportConfig": "Транспорт",
"expireDate": "Тривалість",
+1
View File
@@ -289,6 +289,7 @@
"port": "Cổng",
"portMap": "Ánh xạ cổng",
"traffic": "Lưu lượng",
"speed": "Tốc độ",
"details": "Chi tiết",
"transportConfig": "Truyền dẫn",
"expireDate": "Ngày hết hạn",
+1
View File
@@ -289,6 +289,7 @@
"port": "端口",
"portMap": "端口映射",
"traffic": "流量",
"speed": "速度",
"details": "详细信息",
"transportConfig": "传输",
"expireDate": "到期时间",
+1
View File
@@ -289,6 +289,7 @@
"port": "連接埠",
"portMap": "連接埠對應",
"traffic": "流量",
"speed": "速度",
"details": "詳細資訊",
"transportConfig": "傳輸",
"expireDate": "到期時間",