mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
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:
parent
b2660d43eb
commit
8a34eeedc9
5 changed files with 441 additions and 306 deletions
127
frontend/src/pages/xray/outbounds/OutboundCardList.tsx
Normal file
127
frontend/src/pages/xray/outbounds/OutboundCardList.tsx
Normal 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} 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,15 +3,12 @@ import { useTranslation } from 'react-i18next';
|
|||
import {
|
||||
Button,
|
||||
Col,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Popover,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
|
|
@ -19,27 +16,17 @@ import {
|
|||
CloudOutlined,
|
||||
ApiOutlined,
|
||||
RetweetOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
PlayCircleOutlined,
|
||||
} 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 { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import { useOutboundColumns } from './useOutboundColumns';
|
||||
import OutboundCardList from './OutboundCardList';
|
||||
|
||||
interface OutboundsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
|
|
@ -55,59 +42,6 @@ interface OutboundsTabProps {
|
|||
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({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
|
|
@ -213,171 +147,19 @@ export default function OutboundsTab({
|
|||
});
|
||||
}
|
||||
|
||||
function trafficFor(o: OutboundRow): { up: number; down: number } {
|
||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||
}
|
||||
function isTesting(idx: number): boolean {
|
||||
return !!outboundTestStates?.[idx]?.testing;
|
||||
}
|
||||
function testResult(idx: number) {
|
||||
return outboundTestStates?.[idx]?.result || null;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<OutboundRow> = 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(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} 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],
|
||||
);
|
||||
const columns = useOutboundColumns({
|
||||
testMode,
|
||||
rows,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
openEdit,
|
||||
setFirst,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -422,77 +204,17 @@ export default function OutboundsTab({
|
|||
</Row>
|
||||
|
||||
{isMobile ? (
|
||||
rows.length === 0 ? (
|
||||
<div className="card-empty">—</div>
|
||||
) : (
|
||||
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(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} 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>
|
||||
))
|
||||
)
|
||||
<OutboundCardList
|
||||
rows={rows}
|
||||
testMode={testMode}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
outboundTestStates={outboundTestStates}
|
||||
setFirst={setFirst}
|
||||
openEdit={openEdit}
|
||||
onResetTraffic={onResetTraffic}
|
||||
confirmDelete={confirmDelete}
|
||||
onTest={onTest}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
|
|
|
|||
62
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
Normal file
62
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
Normal 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;
|
||||
}
|
||||
7
frontend/src/pages/xray/outbounds/outbounds-tab-types.ts
Normal file
7
frontend/src/pages/xray/outbounds/outbounds-tab-types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface OutboundRow {
|
||||
key: number;
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
streamSettings?: { network?: string; security?: string };
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
217
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
Normal file
217
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
Normal 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} 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],
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue