;
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) : }
+ {(() => {
+ const speed = inboundSpeed[record.id];
+ if (!isActiveSpeed(speed)) return null;
+ return (
+
+ {t('pages.inbounds.speed')}
+
+
+ );
+ })()}
{clientCount[record.id] && (
{t('clients')}
diff --git a/frontend/src/pages/inbounds/list/types.ts b/frontend/src/pages/inbounds/list/types.ts
index 149269db0..b623e8b22 100644
--- a/frontend/src/pages/inbounds/list/types.ts
+++ b/frontend/src/pages/inbounds/list/types.ts
@@ -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;
onlineClients: string[];
lastOnlineMap: Record;
+ inboundSpeed: Record;
expireDiff: number;
trafficDiff: number;
pageSize: number;
diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx
index 00d6fd41d..4839ed213 100644
--- a/frontend/src/pages/inbounds/list/useInboundColumns.tsx
+++ b/frontend/src/pages/inbounds/list/useInboundColumns.tsx
@@ -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;
clientCount: Record;
+ inboundSpeed: Record;
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({
),
},
+ {
+ title: t('pages.inbounds.speed'),
+ key: 'speed',
+ align: 'center',
+ width: 90,
+ render: (_, record) => {
+ const speed = inboundSpeed[record.id];
+ if (!isActiveSpeed(speed)) {
+ return —;
+ }
+ return ;
+ },
+ },
{
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]);
}
diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts
index 1b1111ad0..3006b258f 100644
--- a/frontend/src/pages/inbounds/useInbounds.ts
+++ b/frontend/src/pages/inbounds/useInbounds.ts
@@ -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;
+// 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>({});
const [statsVersion, setStatsVersion] = useState(0);
+ const [inboundSpeed, setInboundSpeed] = useState>({});
+
const [onlineClients, setOnlineClients] = useState([]);
const onlineClientsRef = useRef([]);
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; activeInbounds?: Record; lastOnlineMap?: Record };
+ const p = payload as {
+ traffics?: TrafficDelta[];
+ onlineClients?: string[];
+ onlineByGuid?: Record;
+ activeInbounds?: Record;
+ lastOnlineMap?: Record;
+ };
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();
+ 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 = {};
+ 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,
diff --git a/frontend/src/test/size-formatter.test.ts b/frontend/src/test/size-formatter.test.ts
new file mode 100644
index 000000000..9788193d2
--- /dev/null
+++ b/frontend/src/test/size-formatter.test.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index dd5d12a4d..eda03c8e4 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -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 {
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 556a7ba2c..33cb47a06 100644
--- a/internal/web/translation/ar-EG.json
+++ b/internal/web/translation/ar-EG.json
@@ -289,6 +289,7 @@
"port": "المنفذ",
"portMap": "تعيين المنفذ",
"traffic": "حركة المرور",
+ "speed": "السرعة",
"details": "تفاصيل",
"transportConfig": "النقل",
"expireDate": "المدة",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 349771f37..3a6a8193b 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Port Mapping",
"traffic": "Traffic",
+ "speed": "Speed",
"details": "Details",
"transportConfig": "Transport",
"expireDate": "Duration",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index 25e8d6d9f..180dd118d 100644
--- a/internal/web/translation/es-ES.json
+++ b/internal/web/translation/es-ES.json
@@ -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",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 6d2528f35..802fa3d33 100644
--- a/internal/web/translation/fa-IR.json
+++ b/internal/web/translation/fa-IR.json
@@ -289,6 +289,7 @@
"port": "پورت",
"portMap": "نگاشت پورت",
"traffic": "ترافیک",
+ "speed": "سرعت",
"details": "توضیحات",
"transportConfig": "انتقال",
"expireDate": "مدت زمان",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 82310180d..4521545db 100644
--- a/internal/web/translation/id-ID.json
+++ b/internal/web/translation/id-ID.json
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Pemetaan port",
"traffic": "Trafik",
+ "speed": "Kecepatan",
"details": "Rincian",
"transportConfig": "Transport",
"expireDate": "Durasi",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 62846ce39..4fe8a3a15 100644
--- a/internal/web/translation/ja-JP.json
+++ b/internal/web/translation/ja-JP.json
@@ -289,6 +289,7 @@
"port": "ポート",
"portMap": "ポートマッピング",
"traffic": "トラフィック",
+ "speed": "速度",
"details": "詳細情報",
"transportConfig": "トランスポート",
"expireDate": "有効期限",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 2f679b020..ab2548d2e 100644
--- a/internal/web/translation/pt-BR.json
+++ b/internal/web/translation/pt-BR.json
@@ -289,6 +289,7 @@
"port": "Porta",
"portMap": "Mapeamento de portas",
"traffic": "Tráfego",
+ "speed": "Velocidade",
"details": "Detalhes",
"transportConfig": "Transporte",
"expireDate": "Duração",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index ac49a4c02..6b0ec174e 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -289,6 +289,7 @@
"port": "Порт",
"portMap": "Сопоставление портов",
"traffic": "Трафик",
+ "speed": "Скорость",
"details": "Подробнее",
"transportConfig": "Транспорт",
"expireDate": "Дата окончания",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index 2bc8fb16b..823a1ccc9 100644
--- a/internal/web/translation/tr-TR.json
+++ b/internal/web/translation/tr-TR.json
@@ -289,6 +289,7 @@
"port": "Port",
"portMap": "Port Eşlemesi",
"traffic": "Trafik",
+ "speed": "Hız",
"details": "Detaylar",
"transportConfig": "Aktarım",
"expireDate": "Süre",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index 787fbd661..662e00cea 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -289,6 +289,7 @@
"port": "Порт",
"portMap": "Відображення портів",
"traffic": "Трафік",
+ "speed": "Швидкість",
"details": "Деталі",
"transportConfig": "Транспорт",
"expireDate": "Тривалість",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index a880b6566..455e64641 100644
--- a/internal/web/translation/vi-VN.json
+++ b/internal/web/translation/vi-VN.json
@@ -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",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 15d652228..a0750bdf4 100644
--- a/internal/web/translation/zh-CN.json
+++ b/internal/web/translation/zh-CN.json
@@ -289,6 +289,7 @@
"port": "端口",
"portMap": "端口映射",
"traffic": "流量",
+ "speed": "速度",
"details": "详细信息",
"transportConfig": "传输",
"expireDate": "到期时间",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 071915539..248472c32 100644
--- a/internal/web/translation/zh-TW.json
+++ b/internal/web/translation/zh-TW.json
@@ -289,6 +289,7 @@
"port": "連接埠",
"portMap": "連接埠對應",
"traffic": "流量",
+ "speed": "速度",
"details": "詳細資訊",
"transportConfig": "傳輸",
"expireDate": "到期時間",