mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "المنفذ",
|
||||
"portMap": "تعيين المنفذ",
|
||||
"traffic": "حركة المرور",
|
||||
"speed": "السرعة",
|
||||
"details": "تفاصيل",
|
||||
"transportConfig": "النقل",
|
||||
"expireDate": "المدة",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Port",
|
||||
"portMap": "Port Mapping",
|
||||
"traffic": "Traffic",
|
||||
"speed": "Speed",
|
||||
"details": "Details",
|
||||
"transportConfig": "Transport",
|
||||
"expireDate": "Duration",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "پورت",
|
||||
"portMap": "نگاشت پورت",
|
||||
"traffic": "ترافیک",
|
||||
"speed": "سرعت",
|
||||
"details": "توضیحات",
|
||||
"transportConfig": "انتقال",
|
||||
"expireDate": "مدت زمان",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Port",
|
||||
"portMap": "Pemetaan port",
|
||||
"traffic": "Trafik",
|
||||
"speed": "Kecepatan",
|
||||
"details": "Rincian",
|
||||
"transportConfig": "Transport",
|
||||
"expireDate": "Durasi",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "ポート",
|
||||
"portMap": "ポートマッピング",
|
||||
"traffic": "トラフィック",
|
||||
"speed": "速度",
|
||||
"details": "詳細情報",
|
||||
"transportConfig": "トランスポート",
|
||||
"expireDate": "有効期限",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Porta",
|
||||
"portMap": "Mapeamento de portas",
|
||||
"traffic": "Tráfego",
|
||||
"speed": "Velocidade",
|
||||
"details": "Detalhes",
|
||||
"transportConfig": "Transporte",
|
||||
"expireDate": "Duração",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Порт",
|
||||
"portMap": "Сопоставление портов",
|
||||
"traffic": "Трафик",
|
||||
"speed": "Скорость",
|
||||
"details": "Подробнее",
|
||||
"transportConfig": "Транспорт",
|
||||
"expireDate": "Дата окончания",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Port",
|
||||
"portMap": "Port Eşlemesi",
|
||||
"traffic": "Trafik",
|
||||
"speed": "Hız",
|
||||
"details": "Detaylar",
|
||||
"transportConfig": "Aktarım",
|
||||
"expireDate": "Süre",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "Порт",
|
||||
"portMap": "Відображення портів",
|
||||
"traffic": "Трафік",
|
||||
"speed": "Швидкість",
|
||||
"details": "Деталі",
|
||||
"transportConfig": "Транспорт",
|
||||
"expireDate": "Тривалість",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "端口",
|
||||
"portMap": "端口映射",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"details": "详细信息",
|
||||
"transportConfig": "传输",
|
||||
"expireDate": "到期时间",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"port": "連接埠",
|
||||
"portMap": "連接埠對應",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"details": "詳細資訊",
|
||||
"transportConfig": "傳輸",
|
||||
"expireDate": "到期時間",
|
||||
|
||||
Reference in New Issue
Block a user