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