diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 3d2a27cf8..95197c748 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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} diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx index dd49b0dd0..c17d074e8 100644 --- a/frontend/src/pages/inbounds/list/InboundList.tsx +++ b/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)} diff --git a/frontend/src/pages/inbounds/list/InboundSpeedTag.tsx b/frontend/src/pages/inbounds/list/InboundSpeedTag.tsx new file mode 100644 index 000000000..36c60265b --- /dev/null +++ b/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 = ( + + ↑ {SizeFormatter.speedFormat(speed.up)} + {' / '} + ↓ {SizeFormatter.speedFormat(speed.down)} + + ); + if (!withTooltip) return tag; + return ( + +
↑ {SizeFormatter.speedFormat(speed.up)}
+
↓ {SizeFormatter.speedFormat(speed.down)}
+ + )} + > + {tag} +
+ ); +} diff --git a/frontend/src/pages/inbounds/list/InboundStatsModal.tsx b/frontend/src/pages/inbounds/list/InboundStatsModal.tsx index 3468d4cd0..3bdb00241 100644 --- a/frontend/src/pages/inbounds/list/InboundStatsModal.tsx +++ b/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; clientCount: Record; + inboundSpeed: Record; 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": "到期時間",