mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 22:17:13 +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:
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user