import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Col, Dropdown, Modal, Popconfirm, Popover, Radio, Row, Space, Table, Tag, Tooltip, } from 'antd'; import { PlusOutlined, 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 { Protocols } from '@/models/outbound'; import OutboundFormModal from './OutboundFormModal'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; interface OutboundsTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; outboundsTraffic: OutboundTrafficRow[]; outboundTestStates: Record; testingAll: boolean; inboundTags: string[]; isMobile: boolean; onResetTraffic: (tag: string) => void; onTest: (index: number, mode: string) => void; onTestAll: (mode: string) => void; onShowWarp: () => void; onShowNord: () => void; } interface OutboundRow { key: number; tag?: string; protocol?: string; streamSettings?: { network?: string; security?: string }; settings?: Record; } function outboundAddresses(o: OutboundRow): string[] { const settings = o.settings as Record | 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[]; ttfbMs?: number; tlsMs?: number; connectMs?: number; dnsMs?: number; statusCode?: number; error?: string } | null | undefined): boolean { if (!r) return false; if (r.endpoints?.length) return true; return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error); } export default function OutboundsTab({ templateSettings, setTemplateSettings, outboundsTraffic, outboundTestStates, testingAll, inboundTags: _inboundTags, isMobile, onResetTraffic, onTest, onTestAll, onShowWarp, onShowNord, }: OutboundsTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp'); const [modalOpen, setModalOpen] = useState(false); const [editingOutbound, setEditingOutbound] = useState | null>(null); const [editingIndex, setEditingIndex] = useState(null); const [existingTags, setExistingTags] = useState([]); const outbounds = useMemo( () => (templateSettings?.outbounds || []) as OutboundRow[], [templateSettings?.outbounds], ); const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]); const mutate = useCallback( (mutator: (next: XraySettingsValue) => void) => { setTemplateSettings((prev) => { if (!prev) return prev; const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue; mutator(clone); return clone; }); }, [setTemplateSettings], ); function openAdd() { setEditingOutbound(null); setEditingIndex(null); setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg)); setModalOpen(true); } function openEdit(idx: number) { setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record); setEditingIndex(idx); setExistingTags( (templateSettings?.outbounds || []) .filter((_, i) => i !== idx) .map((o) => o?.tag) .filter((tg): tg is string => !!tg), ); setModalOpen(true); } function onConfirm(outbound: Record) { mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; if (editingIndex == null) { if (!outbound.tag) return; tt.outbounds.push(outbound as never); } else { tt.outbounds[editingIndex] = outbound as never; } }); setModalOpen(false); } function confirmDelete(idx: number) { modal.confirm({ title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`, okText: t('delete'), okType: 'danger', cancelText: t('cancel'), onOk: () => { mutate((tt) => { tt.outbounds?.splice(idx, 1); }); }, }); } function setFirst(idx: number) { mutate((tt) => { if (!tt.outbounds) return; const [moved] = tt.outbounds.splice(idx, 1); tt.outbounds.unshift(moved); }); } function moveUp(idx: number) { if (idx <= 0) return; mutate((tt) => { if (!tt.outbounds) return; [tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]]; }); } function moveDown(idx: number) { mutate((tt) => { if (!tt.outbounds || idx >= tt.outbounds.length - 1) return; [tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]]; }); } 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 = useMemo( () => [ { title: '#', key: 'action', align: 'center', width: 100, render: (_v, _record, index) => (
{index + 1}
), }, { title: 'Tag', key: 'identity', align: 'left', render: (_v, record) => (
{record.tag}
{record.protocol} {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && ( <> {record.streamSettings?.network} {showSecurity(record.streamSettings?.security) && {record.streamSettings?.security}} )}
), }, { title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => { const addrs = outboundAddresses(record); return (
{addrs.length === 0 ? ( ) : ( addrs.map((addr) => ( {addr} )) )}
); }, }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => { const tr = trafficFor(record); return ( <> ↑ {SizeFormatter.sizeFormat(tr.up)} ↓ {SizeFormatter.sizeFormat(tr.down)} ); }, }, { title: 'Latency', key: 'testResult', align: 'left', width: 140, render: (_v, _record, index) => { const r = testResult(index); if (!r) return isTesting(index) ? : ; return (
{r.success ? {r.delay} ms : {r.error || 'failed'}} {r.mode && {String(r.mode).toUpperCase()}}
{hasBreakdown(r) && ( <> {r.ttfbMs ?
TTFB: {r.ttfbMs} ms
: null} {r.tlsMs ?
TLS: {r.tlsMs} ms
: null} {r.connectMs ?
Connect: {r.connectMs} ms
: null} {r.dnsMs ?
DNS: {r.dnsMs} ms
: null} {r.statusCode ?
HTTP {r.statusCode}
: null} {(r.endpoints || []).map((ep) => (
{ep.address} {ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
))} )} } > {r.success ? : } {r.success ? {r.delay} ms : failed}
); }, }, { title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record, index) => ( setTestMode(e.target.value)} buttonStyle="solid" size="small"> TCP HTTP onResetTraffic('-alltags-')} >