Преглед изворни кода

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 <[email protected]>
Nikan Zeyaei пре 16 часа
родитељ
комит
f4bbaf40f0

+ 2 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -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}

+ 3 - 0
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -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)}

+ 39 - 0
frontend/src/pages/inbounds/list/InboundSpeedTag.tsx

@@ -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>
+  );
+}

+ 14 - 1
frontend/src/pages/inbounds/list/InboundStatsModal.tsx

@@ -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>

+ 6 - 0
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<number, ClientCountEntry>;
   onlineClients: string[];
   lastOnlineMap: Record<string, number>;
+  inboundSpeed: Record<number, InboundSpeedEntry>;
   expireDiff: number;
   trafficDiff: number;
   pageSize: number;

+ 18 - 2
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<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
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<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 - 0
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');
+  });
+});

+ 6 - 1
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 {

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -289,6 +289,7 @@
       "port": "المنفذ",
       "portMap": "تعيين المنفذ",
       "traffic": "حركة المرور",
+      "speed": "السرعة",
       "details": "تفاصيل",
       "transportConfig": "النقل",
       "expireDate": "المدة",

+ 1 - 0
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",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -289,6 +289,7 @@
       "port": "پورت",
       "portMap": "نگاشت پورت",
       "traffic": "ترافیک",
+      "speed": "سرعت",
       "details": "توضیحات",
       "transportConfig": "انتقال",
       "expireDate": "مدت زمان",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -289,6 +289,7 @@
       "port": "ポート",
       "portMap": "ポートマッピング",
       "traffic": "トラフィック",
+      "speed": "速度",
       "details": "詳細情報",
       "transportConfig": "トランスポート",
       "expireDate": "有効期限",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -289,6 +289,7 @@
       "port": "Порт",
       "portMap": "Сопоставление портов",
       "traffic": "Трафик",
+      "speed": "Скорость",
       "details": "Подробнее",
       "transportConfig": "Транспорт",
       "expireDate": "Дата окончания",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -289,6 +289,7 @@
       "port": "Порт",
       "portMap": "Відображення портів",
       "traffic": "Трафік",
+      "speed": "Швидкість",
       "details": "Деталі",
       "transportConfig": "Транспорт",
       "expireDate": "Тривалість",

+ 1 - 0
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",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -289,6 +289,7 @@
       "port": "端口",
       "portMap": "端口映射",
       "traffic": "流量",
+      "speed": "速度",
       "details": "详细信息",
       "transportConfig": "传输",
       "expireDate": "到期时间",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -289,6 +289,7 @@
       "port": "連接埠",
       "portMap": "連接埠對應",
       "traffic": "流量",
+      "speed": "速度",
       "details": "詳細資訊",
       "transportConfig": "傳輸",
       "expireDate": "到期時間",