refactor(frontend): break down OutboundsTab into sections

Extract OutboundsTab's pieces: outbounds-tab-types.ts (OutboundRow),
outbounds-tab-helpers.ts (address/untestable/security/breakdown +
traffic/testing/result accessors), useOutboundColumns.tsx (desktop table
columns hook) and OutboundCardList.tsx (mobile card view). OutboundsTab
stays the orchestrator for outbound state, mutate, reorder and the
toolbar, and drops from 516 to 238 lines. No behavior change.

This completes plan section 2.4.5 — all four oversized Xray tabs
(Basics/Routing/Dns/Outbounds) are now broken into sections + hooks.
This commit is contained in:
MHSanaei 2026-05-30 20:56:31 +02:00
parent b2660d43eb
commit 8a34eeedc9
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 441 additions and 306 deletions

View file

@ -0,0 +1,127 @@
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
} from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import {
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface OutboundCardListProps {
rows: OutboundRow[];
testMode: 'tcp' | 'http';
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
setFirst: (idx: number) => void;
openEdit: (idx: number) => void;
onResetTraffic: (tag: string) => void;
confirmDelete: (idx: number) => void;
onTest: (index: number, mode: string) => void;
}
export default function OutboundCardList({
rows,
testMode,
outboundsTraffic,
outboundTestStates,
setFirst,
openEdit,
onResetTraffic,
confirmDelete,
onTest,
}: OutboundCardListProps) {
const { t } = useTranslation();
if (rows.length === 0) {
return <div className="card-empty"></div>;
}
return (
<>
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [{ key: 'top', label: <VerticalAlignTopOutlined />, onClick: () => setFirst(index) }]
: []),
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
<div className="address-list">
{outboundAddresses(record).map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))}
</div>
)}
<div className="card-foot">
<span className="traffic-up"> {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
<span className="traffic-sep" />
<span className="traffic-down"> {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
<span className="card-test">
{testResult(outboundTestStates, index) ? (
<span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}>
{testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
) : isTesting(outboundTestStates, index) ? (
<LoadingOutlined />
) : null}
<Button
type="primary"
shape="circle"
size="small"
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
</span>
</div>
</div>
))}
</>
);
}

View file

@ -3,15 +3,12 @@ import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Col, Col,
Dropdown,
Modal, Modal,
Popconfirm, Popconfirm,
Popover,
Radio, Radio,
Row, Row,
Space, Space,
Table, Table,
Tag,
Tooltip, Tooltip,
} from 'antd'; } from 'antd';
import { import {
@ -19,27 +16,17 @@ import {
CloudOutlined, CloudOutlined,
ApiOutlined, ApiOutlined,
RetweetOutlined, RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
PlayCircleOutlined, PlayCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import OutboundFormModal from './OutboundFormModal'; import OutboundFormModal from './OutboundFormModal';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css'; import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
import { useOutboundColumns } from './useOutboundColumns';
import OutboundCardList from './OutboundCardList';
interface OutboundsTabProps { interface OutboundsTabProps {
templateSettings: XraySettingsValue | null; templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate; setTemplateSettings: SetTemplate;
@ -55,59 +42,6 @@ interface OutboundsTabProps {
onShowNord: () => void; onShowNord: () => void;
} }
interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}
function outboundAddresses(o: OutboundRow): string[] {
const settings = o.settings as Record<string, unknown> | undefined;
switch (o.protocol) {
case Protocols.VMess: {
const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.VLESS:
return [`${settings?.address || ''}:${settings?.port || ''}`];
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan: {
const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.DNS: {
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
return addr || port ? [`${addr}:${port}`] : [];
}
case Protocols.Wireguard:
return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean));
default:
return [];
}
}
function isUntestable(o: OutboundRow, mode: string): boolean {
if (!o) return true;
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
return false;
}
function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!r.error;
}
export default function OutboundsTab({ export default function OutboundsTab({
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
@ -213,171 +147,19 @@ export default function OutboundsTab({
}); });
} }
function trafficFor(o: OutboundRow): { up: number; down: number } { const columns = useOutboundColumns({
const tr = outboundsTraffic.find((x) => x.tag === o.tag); testMode,
return { up: tr?.up || 0, down: tr?.down || 0 }; rows,
} outboundsTraffic,
function isTesting(idx: number): boolean { outboundTestStates,
return !!outboundTestStates?.[idx]?.testing; openEdit,
} setFirst,
function testResult(idx: number) { moveUp,
return outboundTestStates?.[idx]?.result || null; moveDown,
} confirmDelete,
onResetTraffic,
const columns: ColumnsType<OutboundRow> = useMemo( onTest,
() => [ });
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className="action-buttons">
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{ key: 'top', label: <><VerticalAlignTopOutlined /> Move to top</>, onClick: () => setFirst(index) },
]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
</div>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty"></span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => {
const tr = trafficFor(record);
return (
<>
<span className="traffic-up"> {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down"> {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
},
},
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, _record, index) => {
const r = testResult(index);
if (!r) return isTesting(index) ? <LoadingOutlined /> : <span className="empty"></span>;
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}></span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
},
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record, index) => (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button
type="primary"
shape="circle"
loading={isTesting(index)}
disabled={isUntestable(record, testMode) || isTesting(index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
</Tooltip>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, testMode, rows, outboundTestStates, outboundsTraffic],
);
return ( return (
<> <>
@ -422,77 +204,17 @@ export default function OutboundsTab({
</Row> </Row>
{isMobile ? ( {isMobile ? (
rows.length === 0 ? ( <OutboundCardList
<div className="card-empty"></div> rows={rows}
) : ( testMode={testMode}
rows.map((record, index) => ( outboundsTraffic={outboundsTraffic}
<div key={record.key} className="outbound-card"> outboundTestStates={outboundTestStates}
<div className="card-head"> setFirst={setFirst}
<div className="card-identity"> openEdit={openEdit}
<span className="card-num">{index + 1}</span> onResetTraffic={onResetTraffic}
<Tooltip title={record.tag}> confirmDelete={confirmDelete}
<span className="tag-name">{record.tag}</span> onTest={onTest}
</Tooltip>
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [{ key: 'top', label: <VerticalAlignTopOutlined />, onClick: () => setFirst(index) }]
: []),
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
<div className="address-list">
{outboundAddresses(record).map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))}
</div>
)}
<div className="card-foot">
<span className="traffic-up"> {SizeFormatter.sizeFormat(trafficFor(record).up)}</span>
<span className="traffic-sep" />
<span className="traffic-down"> {SizeFormatter.sizeFormat(trafficFor(record).down)}</span>
<span className="card-test">
{testResult(index) ? (
<span className={testResult(index)!.success ? 'pill-ok' : 'pill-fail'}>
{testResult(index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{testResult(index)!.success ? <span>{testResult(index)!.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
) : isTesting(index) ? (
<LoadingOutlined />
) : null}
<Button
type="primary"
shape="circle"
size="small"
loading={isTesting(index)}
disabled={isUntestable(record, testMode) || isTesting(index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/> />
</span>
</div>
</div>
))
)
) : ( ) : (
<Table <Table
columns={columns} columns={columns}

View file

@ -0,0 +1,62 @@
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
export function outboundAddresses(o: OutboundRow): string[] {
const settings = o.settings as Record<string, unknown> | undefined;
switch (o.protocol) {
case Protocols.VMess: {
const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.VLESS:
return [`${settings?.address || ''}:${settings?.port || ''}`];
case Protocols.HTTP:
case Protocols.Socks:
case Protocols.Shadowsocks:
case Protocols.Trojan: {
const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
case Protocols.DNS: {
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
return addr || port ? [`${addr}:${port}`] : [];
}
case Protocols.Wireguard:
return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean));
default:
return [];
}
}
export function isUntestable(o: OutboundRow, mode: string): boolean {
if (!o) return true;
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
return false;
}
export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality';
}
export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!r.error;
}
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
return { up: tr?.up || 0, down: tr?.down || 0 };
}
export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
return !!states?.[idx]?.testing;
}
export function testResult(states: Record<number, OutboundTestState>, idx: number) {
return states?.[idx]?.result || null;
}

View file

@ -0,0 +1,7 @@
export interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}

View file

@ -0,0 +1,217 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd';
import {
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import {
hasBreakdown,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface OutboundColumnsParams {
testMode: 'tcp' | 'http';
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
openEdit: (idx: number) => void;
setFirst: (idx: number) => void;
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
}
export function useOutboundColumns({
testMode,
rows,
outboundsTraffic,
outboundTestStates,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
onTest,
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className="action-buttons">
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{ key: 'top', label: <><VerticalAlignTopOutlined /> Move to top</>, onClick: () => setFirst(index) },
]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
</div>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty"></span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up"> {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down"> {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
},
},
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, _record, index) => {
const r = testResult(outboundTestStates, index);
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty"></span>;
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}></span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
},
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record, index) => (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button
type="primary"
shape="circle"
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
</Tooltip>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, testMode, rows, outboundTestStates, outboundsTraffic],
);
}