refactor(frontend): reorganize source tree & break down oversized modals/tabs (#4698)

* refactor(frontend): reorganize components & pages into feature folders

No behavior change; pure file relocation + import path updates.

* refactor(frontend): move shared protocol enums to schemas/protocols/shared

Decouple Outbound from Inbound schemas: SSMethodSchema and VmessSecuritySchema (shared between inbound & outbound) now live in a neutral schemas/protocols/shared/ module. Outbound no longer reaches into schemas/protocols/inbound/*. Pure relocation + import rewiring; schema values identical, snapshots & golden tests unchanged.

* refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal

InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass.

* refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo

InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass.

* test(frontend): add React Testing Library + jsdom render-test harness

- vitest projects: node unit tests stay lean; new jsdom 'components' project runs *.test.tsx
- component setup: matchMedia/ResizeObserver/localStorage polyfills, react-i18next init, persian-calendar-suite stub (only used under jalali locale)
- smoke + field-label structure snapshots for Inbound & Outbound form modals
- establishes the regression net required before decomposing the oversized form modals
- 341 tests pass (337 unit + 4 component); typecheck/lint/build green

* test(frontend): per-protocol field-structure coverage for both form modals

- drive the protocol Select in jsdom and snapshot rendered Form.Item labels for every protocol
- 10 outbound + 10 inbound protocol states captured as the regression net for protocol-core extraction
- add robust select-driving helpers (test-utils) + post-test body cleanup (setup.components)
- 341 tests pass; typecheck/lint green

* refactor(frontend): extract OutboundFormModal constants & stream helpers

OutboundFormModal.tsx 2238 -> 2080. Moved the pure option arrays/sets and the stream-slice helpers (newStreamSlice, hysteriaStreamSlice, isMuxAllowed, buildAddModeValues) into outbound-form-constants.ts and outbound-form-helpers.ts. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract InboundFormModal advanced JSON editors

InboundFormModal.tsx 3129 -> 2863. Moved AdvancedSliceEditor and AdvancedAllEditor (the in-modal JSON slice/all editors) into advanced-editors.tsx along with their adapter-helper imports. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal loopback/blackhole/dns field blocks

Moved the outbound-only protocol field blocks (loopback, blackhole, dns) out of the modal render body into outbound-only-fields.tsx. First render-body extraction behind the per-protocol snapshot net: loopback/blackhole/dns snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal freedom field block

OutboundFormModal.tsx 2063 -> 1753. Moved the freedom protocol field block (domainStrategy, fragment, noises, finalRules) into outbound-freedom-fields.tsx. Verbatim relocation; freedom per-protocol snapshot unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal wireguard field block

OutboundFormModal.tsx 1753 -> 1622. Moved the wireguard protocol field block (address, keypair gen, domainStrategy, peers + allowedIPs) into outbound-wireguard-fields.tsx; dropped now-unused icon/InputAddon/WireguardDomainStrategy imports. Verbatim relocation; wireguard snapshot unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal core protocol fields

OutboundFormModal.tsx 1622 -> 1538. Moved the shared protocol core field blocks (vmess/vless ID, vmess security, vless encryption/reverseTag, trojan/ss password, ss method/uot, socks/http user/pass) into outbound-core-fields.tsx; dropped now-unused schema/option imports. Per-protocol snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): fold OutboundFormModal server address/port block into core fields

OutboundFormModal.tsx 1538 -> 1516. Moved the shared connect-target (address/port) block into OutboundCoreProtocolFields at the same render position; dropped the now-unused SERVER_PROTOCOLS import. Snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound-only protocol forms into per-protocol files

Replace the grouped outbound-only-fields.tsx + outbound-freedom-fields.tsx with one file per protocol under outbounds/protocols/: freedom.tsx, blackhole.tsx, dns.tsx, loopback.tsx (+ barrel). Matches the prompt's 1-file-per-protocol structure. Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound protocol forms into per-protocol files

Replace the grouped outbound-core-fields / outbound-wireguard-fields with one file per protocol under outbounds/protocols/: vmess, vless, trojan, shadowsocks, http, socks, wireguard, freedom, blackhole, dns, loopback (+ shared server-target). Matches the prompt's 1-file-per-protocol structure (per-modal). Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound transport forms into per-transport files

Extract the tcp(raw)/kcp/ws/grpc/httpupgrade transport blocks into outbounds/transport/ per-file components (RawForm, KcpForm, WsForm, GrpcForm, HttpUpgradeForm). xhttp + hysteria transport remain inline for a follow-up. Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal xhttp transport form

Move the xhttp transport block into transport/xhttp.tsx (takes form + onXmuxToggle prop); drop now-unused HeaderMapEditor and MODE_OPTIONS imports from the modal. OutboundFormModal.tsx down to ~1001 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal tls/reality security forms

Move the TLS and Reality field blocks into outbounds/security/{tls,reality}.tsx; the none/TLS/Reality Radio.Group selector stays in the modal. Drop now-unused ALPN_OPTIONS/UTLS_OPTIONS imports. OutboundFormModal.tsx down to ~918 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound-only protocol forms (tun, tunnel) into per-file

Extract the tun and tunnel protocol blocks from InboundFormModal into inbounds/form/protocols/{tun,tunnel}.tsx (presentational, declarative). First inbound-side per-protocol split. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound wireguard & shadowsocks protocol forms

Extract the wireguard and shadowsocks protocol blocks from InboundFormModal into inbounds/form/protocols/{wireguard,shadowsocks}.tsx (presentational; form + regen handlers / isSSWith2022 passed as props). Drop now-unused Divider + SSMethodSchema imports. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound vless/http/mixed/hysteria protocol forms

Extract the remaining inbound protocol blocks into inbounds/form/protocols/: vless (auth handlers/state as props), http + mixed (shared accounts-list), hysteria. Drop now-unused HysteriaMasqueradeForm/Typography/Text imports from the modal. InboundFormModal.tsx 2841 -> 2478. Inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport

The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.

* refactor(frontend): extract inbound transport forms into transport/ folder

Move the six inbound stream-transport blocks (tcp/raw, ws, grpc, xhttp,
httpupgrade, kcp) out of InboundFormModal into presentational components
under inbounds/form/transport/. XhttpForm takes the form instance and
re-derives its mode/obfs/placement watches internally; the rest are
declarative. InboundFormModal drops from 2566 to 2105 lines. No behavior
change — per-protocol field-label snapshots unchanged.

* refactor(frontend): extract inbound security forms into security/ folder

Move the inbound TLS and Reality stream-security blocks out of
InboundFormModal into presentational components under
inbounds/form/security/. The Radio.Group security selector stays in the
modal; TlsForm and RealityForm receive their cert/key/ECH generation
handlers and the saving flag as props. InboundFormModal drops from 2105
to 1708 lines.

Add inbound-form-blocks.test.tsx: render-snapshot coverage for each
extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security
(tls/reality) component in isolation inside a minimal Form. The full
modal cannot exercise the stream/security tabs in jsdom because they are
gated behind Form.useWatch values that do not propagate in the test
harness, so component-level snapshots are the regression net for these
blocks. No behavior change.

* refactor(frontend): extract outbound sockopt/mux/hysteria transport blocks

Move the last three oversized inline stream blocks out of
OutboundFormModal into presentational components under
xray/outbounds/transport/: SockoptForm (~260 lines, the worst offender),
MuxForm, and HysteriaForm. Each takes the form instance; MuxForm also
takes protocol/network and keeps its isMuxAllowed gate. OutboundFormModal
drops from 962 to 621 lines and no inline section now exceeds the
250-line guideline. Existing outbound-form-modal snapshots already cover
sockopt/mux and stay byte-identical, confirming no behavior change.

* refactor(frontend): extract inbound sockopt + external-proxy blocks

Move the inbound Sockopt (~250 lines) and External Proxy stream blocks
out of InboundFormModal into presentational components under
inbounds/form/transport/, mirroring the outbound extraction. Each takes
its toggle handler (toggleSockopt / toggleExternalProxy) as a prop and
keeps its render-prop getFieldValue gate. InboundFormModal drops from
1708 to 1332 lines.

Extend inbound-form-blocks.test.tsx with isolated render-snapshot
coverage for both (SockoptForm seeded enabled + happyEyeballs;
ExternalProxyForm seeded with one TLS entry). No behavior change.

* refactor(frontend): break down RoutingTab into sections

Extract RoutingTab's presentational pieces into the routing/ folder:
helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts
(RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and
useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the
orchestrator holding rule state, mutate, tag-option memos and the
pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior
change.

* refactor(frontend): extract BasicsTab constants and rule helpers

Move BasicsTab's geo option arrays + freedom/ipv4 outbound presets into
basics/constants.ts and the routing-rule get/set/sync helpers into
basics/helpers.ts. BasicsTab drops from 550 to 447 lines and keeps its
Collapse-of-settings panels (which stay coupled to mutate + derived
state, so splitting them into components would only add prop-drilling).
No behavior change.

* refactor(frontend): break down DnsTab columns/helpers/types

Extract DnsTab's pure pieces into the dns/ folder: helpers.ts
(STRATEGIES/DEFAULT_FAKEDNS + addr/domains/expectedIPs accessors),
types.ts (DnsConfig/HostRow/FakednsRow), and useDnsColumns.tsx
(useDnsServerColumns + useFakednsColumns table-column hooks taking their
row handlers as params). DnsTab stays the orchestrator for dns state,
mutate, hosts sync and the Collapse panels, and drops from 539 to 424
lines. No behavior change.

* 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.

* refactor(frontend): fold HysteriaMasqueradeForm into the hysteria forms

Inline the masquerade fields directly into both hysteria transport forms
(inbounds/form/protocols/hysteria + xray/outbounds/transport/hysteria)
and delete the shared lib/xray/forms/transport/HysteriaMasqueradeForm so
each hysteria form is self-contained. The masquerade JSX is unchanged;
form is typed as the untyped FormInstance (as the shared component was)
so the masquerade name paths still resolve. No behavior change.

* refactor(frontend): slim InboundFormModal by extracting hooks + sections

Pull the modal's non-layout logic into focused files at the form root:
- useSecurityActions.ts: TLS/Reality key + cert generation handlers and
  onSecurityChange (consumed by the security tab)
- useInboundFallbacks.ts: fallback row state + load/save/derive/add/
  update/remove/move handlers + eligible-child options
- FallbacksCard.tsx: the fallbacks card UI (presentational)
- SniffingTab.tsx: the sniffing tab UI (presentational)

Also drop the stale "Pattern A rewrite / sibling file" header comment and
the imports the extractions made unused. InboundFormModal goes from 1332
to 868 lines with no behavior change (351 tests green, snapshots
unchanged).
This commit is contained in:
Sanaei
2026-05-30 21:51:33 +02:00
committed by GitHub
parent 84a689cf10
commit d1882c7f29
188 changed files with 10471 additions and 7812 deletions
File diff suppressed because it is too large Load Diff
-516
View File
@@ -1,516 +0,0 @@
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 { 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';
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
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<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,
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<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown 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<string, unknown>);
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<string, unknown>) {
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<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}&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 (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
<Space size="small" wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
WARP
</Button>
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
NordVPN
</Button>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
</Radio.Group>
</Tooltip>
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
{!isMobile && t('pages.xray.outbound.testAll')}
</Button>
<Popconfirm
placement="topRight"
okText={t('reset')}
cancelText={t('cancel')}
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
</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}&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
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
/>
)}
<OutboundFormModal
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</Space>
</>
);
}
-550
View File
@@ -1,550 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tag, Tooltip } from 'antd';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type { RuleObject } from '@/schemas/routing';
import './RoutingTab.css';
interface RoutingTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
isMobile: boolean;
}
interface RuleRow {
key: number;
domain?: string;
ip?: string;
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string;
user?: string;
inboundTag?: string;
protocol?: string;
attrs?: string;
outboundTag?: string;
balancerTag?: string;
}
function arrJoin(v: unknown): string | undefined {
if (v == null) return undefined;
if (Array.isArray(v)) return v.join(',');
return String(v);
}
function csv(value?: string): string[] {
if (!value) return [];
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}
function chipPreview(value?: string): string {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
export default function RoutingTab({
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
isMobile,
}: RoutingTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rules = useMemo(
() => (templateSettings?.routing?.rules || []) as RoutingRule[],
[templateSettings?.routing?.rules],
);
const rulesRef = useRef(rules);
rulesRef.current = rules;
const rows: RuleRow[] = useMemo(
() =>
rules.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.domain = arrJoin(rule.domain);
r.ip = arrJoin(rule.ip);
r.port = rule.port;
r.sourcePort = rule.sourcePort;
r.vlessRoute = rule.vlessRoute;
r.network = rule.network;
r.sourceIP = arrJoin(rule.sourceIP);
r.user = arrJoin(rule.user);
r.inboundTag = arrJoin(rule.inboundTag);
r.protocol = arrJoin(rule.protocol);
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
r.attrs = JSON.stringify(rule.attrs, null, 2);
}
r.outboundTag = rule.outboundTag;
r.balancerTag = rule.balancerTag;
return r;
}),
[rules],
);
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],
);
const inboundTagOptions = useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
const push = (tag?: string) => {
if (!tag || seen.has(tag)) return;
seen.add(tag);
out.push(tag);
};
for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
for (const tag of inboundTags || []) push(tag);
for (const ob of templateSettings?.outbounds || []) {
const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } };
push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
}
push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) {
if (typeof s === 'object' && s?.tag) push(s.tag);
}
return out;
}, [templateSettings, inboundTags]);
const outboundTagOptions = useMemo(() => {
const out = new Set<string>(['']);
for (const ob of templateSettings?.outbounds || []) {
if (ob?.tag) out.add(ob.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags]);
const balancerTagOptions = useMemo(() => {
const out: string[] = [''];
for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) {
if (b?.tag) out.push(b.tag);
}
return out;
}, [templateSettings?.routing?.balancers]);
function openAdd() {
setEditingRule(null);
setEditingIndex(null);
setRuleModalOpen(true);
}
function openEdit(idx: number) {
setEditingRule(rulesRef.current[idx]);
setEditingIndex(idx);
setRuleModalOpen(true);
}
function onRuleConfirm(rule: Record<string, unknown>) {
if (JSON.stringify(rule).length <= 3) {
setRuleModalOpen(false);
return;
}
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
const typed = rule as unknown as RuleObject;
if (editingIndex == null) tt.routing.rules.push(typed);
else tt.routing.rules[editingIndex] = typed;
});
setRuleModalOpen(false);
}
function confirmDelete(idx: number) {
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => {
tt.routing?.rules?.splice(idx, 1);
}),
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
});
}
function moveDown(idx: number) {
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || idx >= list.length - 1) return;
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch { /* ignore */ }
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [movedItem] = list.splice(from, 1);
list.splice(to, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
const desktopColumns: ColumnsType<RuleRow> = useMemo(
() => [
{
title: '#',
align: 'center',
width: 100,
key: 'action',
render: (_v, _r, index) => (
<div className="action-cell">
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rows.length - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.xray.rules.source'),
align: 'left',
width: 180,
key: 'source',
render: (_v, record) => (
<div className="criterion-flow">
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.inbounds.network'),
align: 'left',
width: 180,
key: 'network',
render: (_v, record) => (
<div className="criterion-flow">
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.rules.dest'),
align: 'left',
key: 'destination',
render: (_v, record) => (
<div className="criterion-flow">
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
{!record.ip && !record.domain && !record.port && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.Inbounds'),
align: 'left',
width: 180,
key: 'inbound',
render: (_v, record) => (
<div className="criterion-flow">
{record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
{!record.inboundTag && !record.user && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.Outbounds'),
align: 'left',
width: 170,
key: 'outbound',
render: (_v, record) =>
record.outboundTag ? (
<div className="target-row">
<ExportOutlined className="target-icon" />
<Tag color="green">{record.outboundTag}</Tag>
</div>
) : (
<span className="criterion-empty"></span>
),
},
{
title: t('pages.xray.Balancers'),
align: 'left',
width: 150,
key: 'balancer',
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
<ClusterOutlined className="target-icon" />
<Tag color="purple">{record.balancerTag}</Tag>
</div>
) : (
<span className="criterion-empty"></span>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile, rows.length],
);
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
{isMobile ? (
<div className="rule-list">
{rows.length === 0 ? (
<div className="rule-empty"></div>
) : (
rows.map((rule, index) => (
<div
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
data-row-key={index}
>
<div className="rule-card-head">
<HolderOutlined
className="drag-handle"
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/>
<span className="rule-number">#{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
<div className="rule-flow">
<div className="flow-side">
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
{rule.inboundTag ? (
<Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
) : (
<span className="criterion-empty">any</span>
)}
</div>
<span className="flow-arrow"></span>
<div className="flow-side flow-side-target">
<span className="flow-label">
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
<ExportOutlined /> {rule.outboundTag}
</Tag>
) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag">
<ClusterOutlined /> {rule.balancerTag}
</Tag>
) : (
<span className="criterion-empty"></span>
)}
</div>
</div>
{ruleCriteriaChips(rule).length > 0 && (
<div className="rule-criteria">
{ruleCriteriaChips(rule).map((chip) => (
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
<span className="criterion-chip">
<span className="criterion-chip-label">{chip.label}</span>
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
</span>
</Tooltip>
))}
</div>
)}
</div>
))
)}
</div>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: 1150 }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
}}
/>
)}
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
</Space>
</>
);
}
function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
const parts = csv(value);
if (parts.length === 0) return null;
return (
<Tooltip title={title}>
<span className="criterion-row">
<span className="criterion-label">{label}</span>
<span className="criterion-value">{parts[0]}</span>
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
</span>
</Tooltip>
);
}
+8 -9
View File
@@ -33,17 +33,16 @@ import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar';
import JsonEditor from '@/components/JsonEditor';
import AppSidebar from '@/layouts/AppSidebar';
import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus';
import BasicsTab from './BasicsTab';
import RoutingTab from './RoutingTab';
import OutboundsTab from './OutboundsTab';
import BalancersTab from './BalancersTab';
import DnsTab from './DnsTab';
import WarpModal from './WarpModal';
import NordModal from './NordModal';
import { BasicsTab } from './basics';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers';
import { DnsTab } from './dns';
import { WarpModal, NordModal } from './overrides';
import './XrayPage.css';
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
import { InputAddon } from '@/components/ui';
import {
BalancerFormSchema,
type BalancerFormValues,
@@ -6,7 +6,7 @@ import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import JsonEditor from '@/components/JsonEditor';
import { JsonEditor } from '@/components/form';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type {
BalancerObject,
@@ -0,0 +1,2 @@
export { default as BalancersTab } from './BalancersTab';
export { default as BalancerFormModal } from './BalancerFormModal';
@@ -4,10 +4,26 @@ import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'an
import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import SettingListItem from '@/components/SettingListItem';
import { SettingListItem } from '@/components/ui';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
import {
ACCESS_LOG,
BITTORRENT_PROTOCOLS,
BLOCK_DOMAINS_OPTIONS,
DOMAINS_OPTIONS,
ERROR_LOG,
IPS_OPTIONS,
LOG_LEVELS,
MASK_ADDRESS,
ROUTING_DOMAIN_STRATEGIES,
SERVICES_OPTIONS,
directSettings,
ipv4Settings,
} from './constants';
import { ruleGetter, ruleSetter, syncOutbound } from './helpers';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
@@ -20,125 +36,6 @@ interface BasicsTabProps {
onResetDefault: () => void;
}
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
const ACCESS_LOG = ['none', './access.log'];
const ERROR_LOG = ['none', './error.log'];
const MASK_ADDRESS = ['quarter', 'half', 'full'];
const BITTORRENT_PROTOCOLS = ['bittorrent'];
const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
const directSettings = { tag: 'direct', protocol: 'freedom' };
const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
if (!t?.routing?.rules) return [];
const out: string[] = [];
for (const rule of t.routing.rules) {
if (
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag
) {
const v = (rule as Record<string, unknown>)[property];
if (Array.isArray(v)) out.push(...(v as string[]));
}
}
return out;
}
function ruleSetter(t: XraySettingsValue, outboundTag: string, property: string, data: string[]): void {
if (!t.routing) return;
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
const current = ruleGetter(t, outboundTag, property);
if (current.length === 0) {
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
return;
}
const next: typeof t.routing.rules = [];
let inserted = false;
for (const rule of t.routing.rules) {
const matches =
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
(rule as Record<string, unknown>)[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
t.routing.rules = next;
}
function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
if (!t.outbounds || !t.routing) return;
const rules = t.routing.rules || [];
const haveRules = rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
@@ -0,0 +1,63 @@
export const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
export const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
export const ACCESS_LOG = ['none', './access.log'];
export const ERROR_LOG = ['none', './error.log'];
export const MASK_ADDRESS = ['quarter', 'half', 'full'];
export const BITTORRENT_PROTOCOLS = ['bittorrent'];
export const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
export const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
export const directSettings = { tag: 'direct', protocol: 'freedom' };
export const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
+56
View File
@@ -0,0 +1,56 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
export function ruleGetter(t: XraySettingsValue | null, outboundTag: string, property: string): string[] {
if (!t?.routing?.rules) return [];
const out: string[] = [];
for (const rule of t.routing.rules) {
if (
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag
) {
const v = (rule as Record<string, unknown>)[property];
if (Array.isArray(v)) out.push(...(v as string[]));
}
}
return out;
}
export function ruleSetter(t: XraySettingsValue, outboundTag: string, property: string, data: string[]): void {
if (!t.routing) return;
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
const current = ruleGetter(t, outboundTag, property);
if (current.length === 0) {
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
return;
}
const next: typeof t.routing.rules = [];
let inserted = false;
for (const rule of t.routing.rules) {
const matches =
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
(rule as Record<string, unknown>)[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
t.routing.rules = next;
}
export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
if (!t.outbounds || !t.routing) return;
const rules = t.routing.rules || [];
const haveRules = rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
+1
View File
@@ -0,0 +1 @@
export { default as BasicsTab } from './BasicsTab';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
import { InputAddon } from '@/components/ui';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
@@ -1,49 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Dropdown, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd';
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { Button, Collapse, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd';
import { PlusOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons';
import SettingListItem from '@/components/SettingListItem';
import { SettingListItem } from '@/components/ui';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import { DnsQueryStrategySchema, type DnsObject } from '@/schemas/dns';
import './DnsTab.css';
import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
import type { DnsConfig, HostRow, FakednsRow } from './types';
import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
const STRATEGIES = DnsQueryStrategySchema.options;
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
interface HostRow {
domain: string;
values: string[];
}
interface FakednsRow {
ipPool: string;
poolSize: number;
}
function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
@@ -142,52 +117,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
const dnsColumns: ColumnsType<{ key: number; server: DnsServerValue }> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEditServer(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => deleteServer(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() {
setEditingServer(null);
@@ -241,52 +171,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const fakednsColumns: ColumnsType<{ key: number; ipPool: string; poolSize: number }> = useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
/>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() {
mutate((tt) => {
+19
View File
@@ -0,0 +1,19 @@
import { DnsQueryStrategySchema } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export const STRATEGIES = DnsQueryStrategySchema.options;
export const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
export function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
export function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
export function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
+3
View File
@@ -0,0 +1,3 @@
export { default as DnsTab } from './DnsTab';
export { default as DnsServerModal } from './DnsServerModal';
export { default as DnsPresetsModal } from './DnsPresetsModal';
+14
View File
@@ -0,0 +1,14 @@
import type { DnsObject } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
export interface HostRow {
domain: string;
values: string[];
}
export interface FakednsRow {
ipPool: string;
poolSize: number;
}
@@ -0,0 +1,122 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
export type DnsServerRow = { key: number; server: DnsServerValue };
export type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
export function useDnsServerColumns({
openEditServer,
deleteServer,
}: {
openEditServer: (idx: number) => void;
deleteServer: (idx: number) => void;
}): ColumnsType<DnsServerRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEditServer(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => deleteServer(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t],
);
}
export function useFakednsColumns({
deleteFakedns,
updateFakednsField,
}: {
deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> {
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
/>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
}
@@ -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>
))}
</>
);
}
@@ -0,0 +1,621 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Switch,
Tabs,
message,
} from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import {
XMUX_DEFAULTS,
formValuesToWirePayload,
rawOutboundToFormValues,
} from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import {
OutboundFormBaseSchema,
type OutboundFormValues,
} from '@/schemas/forms/outbound-form';
import { SNIFFING_OPTION } from '@/schemas/primitives';
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import { antdRule } from '@/utils/zodForm';
import {
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
NETWORK_OPTIONS,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
} from './outbound-form-constants';
import {
buildAddModeValues,
hysteriaStreamSlice,
newStreamSlice,
} from './outbound-form-helpers';
import {
BlackholeFields,
DnsFields,
FreedomFields,
HttpFields,
LoopbackFields,
ServerTarget,
ShadowsocksFields,
SocksFields,
TrojanFields,
VlessFields,
VmessFields,
WireguardFields,
} from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
HysteriaForm,
KcpForm,
MuxForm,
RawForm,
SockoptForm,
WsForm,
XhttpForm,
} from './transport';
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
// file so the build stays green section-by-section. The atomic swap at
// the end of the rewrite replaces the legacy file in one commit
// (per Core Decision 7 in the migration spec).
interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<OutboundFormValues>();
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
// hysteria2:// / wireguard://) and replace form state with the result.
// The current tag is preserved when the parsed link doesn't carry one.
function importLink() {
const link = linkInput.trim();
if (!link) return;
const parsed = parseOutboundLink(link);
if (!parsed) {
messageApi.error('Wrong Link!');
return;
}
const currentTag = form.getFieldValue('tag') as string | undefined;
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
messageApi.success('Link imported successfully');
switchTab('1');
}
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
useEffect(() => {
if (!open) return;
const initial = outboundProp
? rawOutboundToFormValues(outboundProp)
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
}, [open, outboundProp, form]);
const tag = Form.useWatch('tag', form) ?? '';
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
useEffect(() => {
if (!streamAllowed) return;
if (network) return;
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamAllowed, network]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
form.setFieldValue('streamSettings', slice);
}, [protocol, network, security]);
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
form.setFieldValue(['settings', 'pubKey'], '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
form.setFieldValue(['settings', 'pubKey'], publicKey);
} catch {
form.setFieldValue(['settings', 'pubKey'], '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [protocol, wgSecretKey]);
function onValuesChange(changed: Partial<OutboundFormValues>) {
if ('protocol' in changed && changed.protocol) {
const next = rawOutboundToFormValues({ protocol: changed.protocol });
form.setFieldValue('settings', next.settings);
if (changed.protocol === 'hysteria') {
form.setFieldValue('streamSettings', hysteriaStreamSlice());
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
}
}
}
function onSecurityChange(next: string) {
const stream = form.getFieldValue('streamSettings') ?? {};
const cleaned = { ...stream } as Record<string, unknown>;
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
cleaned.tlsSettings = {
serverName: '',
alpn: [],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: '',
};
} else if (next === 'reality') {
cleaned.realitySettings = {
publicKey: '',
fingerprint: 'chrome',
serverName: '',
shortId: '',
spiderX: '',
mldsa65Verify: '',
};
}
cleaned.security = next;
form.setFieldValue('streamSettings', cleaned);
}
// Network change cascade: swap the per-network sub-key (tcpSettings,
// wsSettings, etc.) so the DU branch matches. Preserve security if
// the new network supports it, otherwise force back to 'none'.
function onNetworkChange(next: string) {
if (next === 'hysteria') {
form.setFieldValue('streamSettings', hysteriaStreamSlice());
return;
}
const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
const newSecurity =
currentSecurity === 'tls' && !stillAllowed
? 'none'
: currentSecurity === 'reality' && !stillReality
? 'none'
: currentSecurity;
form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
}
const duplicateTag = useMemo(() => {
const myTag = tag.trim();
if (!myTag) return false;
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
// Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
// any edits into form state. When entering JSON tab, snapshot current
// form values so the user sees the live shape.
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
if (!raw) return true;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
const next = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(next);
setJsonDirty(false);
return true;
}
function switchTab(key: string) {
if (typeof document !== 'undefined') {
(document.activeElement as HTMLElement | null)?.blur?.();
}
setActiveKey(key);
}
function onTabChange(key: string) {
if (key === '2') {
const values = form.getFieldsValue(true) as OutboundFormValues;
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
return;
}
if (key === '1' && activeKey === '2') {
if (!applyJsonToForm()) return;
}
switchTab(key);
}
async function onOk() {
let values: OutboundFormValues;
if (activeKey === '2') {
const raw = jsonText.trim();
if (!raw) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return;
}
values = rawOutboundToFormValues(parsed);
form.resetFields();
form.setFieldsValue(values);
setJsonDirty(false);
} else {
try {
await form.validateFields();
} catch {
return;
}
values = form.getFieldsValue(true) as OutboundFormValues;
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
return;
}
const isDuplicateTag = (existingTags || []).includes(tagValue)
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
if (isDuplicateTag) {
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(formValuesToWirePayload(values));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
destroyOnHidden
>
<Form
form={form}
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
onValuesChange={onValuesChange}
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<Form.Item
label={t('protocol')}
name="protocol"
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
>
<Select options={PROTOCOL_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outbound.tag')}
name="tag"
validateStatus={duplicateTag ? 'warning' : undefined}
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
rules={[
{ required: true, message: t('pages.xray.outboundForm.tagRequired') },
]}
>
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</Form.Item>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields form={form} />}
{protocol === 'vless' && (
<Form.Item shouldUpdate noStyle>
{() => {
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
if (!reverseTag) return null;
const sniff = (form.getFieldValue(['settings', 'reverseSniffing']) ?? {}) as {
enabled?: boolean;
};
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.reverseSniffing')}
name={['settings', 'reverseSniffing', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
{sniff.enabled && (
<>
<Form.Item
wrapperCol={{ md: { span: 14, offset: 8 } }}
name={['settings', 'reverseSniffing', 'destOverride']}
>
<Select
mode="multiple"
className="sniffing-options"
options={Object.entries(SNIFFING_OPTION).map(([k, v]) => ({
value: v,
label: k,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}
name={['settings', 'reverseSniffing', 'metadataOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingRouteOnly')}
name={['settings', 'reverseSniffing', 'routeOnly']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingIpsExcluded')}
name={['settings', 'reverseSniffing', 'ipsExcluded']}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*"
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingDomainsExcluded')}
name={['settings', 'reverseSniffing', 'domainsExcluded']}
>
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*"
/>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
)}
{protocol === 'wireguard' && <WireguardFields form={form} />}
{streamAllowed && network && (
<>
<Form.Item
label={t('transmission')}
name={['streamSettings', 'network']}
>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{network === 'tcp' && <RawForm form={form} />}
{network === 'kcp' && <KcpForm />}
{network === 'ws' && <WsForm />}
{network === 'grpc' && <GrpcForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm form={form} />}
</>
)}
{tlsFlowAllowed && (
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/>
</Form.Item>
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). The
legacy class gated this on `canEnableVisionSeed()`
— same condition encoded inline here. */}
<Form.Item shouldUpdate noStyle>
{() => {
const flow =
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
return (
<>
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[900, 500, 900, 256].map((def, i) => (
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
<InputNumber min={1} style={{ width: '25%' }} />
</Form.Item>
))}
</Space.Compact>
</Form.Item>
</>
);
}}
</Form.Item>
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed) && <SockoptForm form={form} />}
<FinalMaskForm
name={['streamSettings', 'finalmask']}
network={network}
protocol={protocol}
form={form}
/>
<MuxForm form={form} protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,238 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Modal,
Popconfirm,
Radio,
Row,
Space,
Table,
Tooltip,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
RetweetOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import OutboundFormModal from './OutboundFormModal';
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;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
testingAll: boolean;
inboundTags: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
}
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<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown 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<string, unknown>);
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<string, unknown>) {
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]];
});
}
const columns = useOutboundColumns({
testMode,
rows,
outboundsTraffic,
outboundTestStates,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
onTest,
});
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
<Space size="small" wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
WARP
</Button>
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
NordVPN
</Button>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
</Radio.Group>
</Tooltip>
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
{!isMobile && t('pages.xray.outbound.testAll')}
</Button>
<Popconfirm
placement="topRight"
okText={t('reset')}
cancelText={t('cancel')}
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
</Row>
{isMobile ? (
<OutboundCardList
rows={rows}
testMode={testMode}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
setFirst={setFirst}
openEdit={openEdit}
onResetTraffic={onResetTraffic}
confirmDelete={confirmDelete}
onTest={onTest}
/>
) : (
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
/>
)}
<OutboundFormModal
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</Space>
</>
);
}
@@ -0,0 +1,2 @@
export { default as OutboundsTab } from './OutboundsTab';
export { default as OutboundFormModal } from './OutboundFormModal';
@@ -0,0 +1,47 @@
import {
ALPN_OPTION,
Address_Port_Strategy,
MODE_OPTION,
OutboundProtocols as Protocols,
TLS_FLOW_CONTROL,
USERS_SECURITY,
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
export const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
export const SECURITY_OPTIONS = Object.values(USERS_SECURITY).map((v) => ({ value: v, label: v }));
export const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL).map((v) => ({ value: v, label: v }));
export const SS_METHOD_OPTIONS = SSMethodSchema.options.map((v) => ({ value: v, label: v }));
export const MODE_OPTIONS = Object.values(MODE_OPTION).map((v) => ({ value: v, label: v }));
export const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v }));
export const ALPN_OPTIONS = Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v }));
export const ADDRESS_PORT_STRATEGY_OPTIONS = Object.values(Address_Port_Strategy).map((v) => ({
value: v,
label: v,
}));
// canEnableMux mirrors the adapter's helper but lives here so the modal
// can show/hide the Mux section without going through the adapter.
export const MUX_PROTOCOLS = new Set<string>(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
export const NETWORK_OPTIONS: { value: string; label: string }[] = [
{ value: 'tcp', label: 'RAW' },
{ value: 'kcp', label: 'mKCP' },
{ value: 'ws', label: 'WebSocket' },
{ value: 'grpc', label: 'gRPC' },
{ value: 'httpupgrade', label: 'HTTPUpgrade' },
{ value: 'xhttp', label: 'XHTTP' },
];
// The hysteria protocol is locked to its own QUIC transport: the selector
// shows only this option when the parent protocol is hysteria.
export const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
// Protocols whose form schema carries a flat connect target — these all
// get the shared "server" sub-block (address + port) at the top of the
// protocol section. Wireguard has an address but no port. DNS/freedom/
// blackhole/loopback have no connect target.
export const SERVER_PROTOCOLS = new Set<string>([
'vmess', 'vless', 'trojan', 'shadowsocks', 'socks', 'http', 'hysteria',
]);
@@ -0,0 +1,79 @@
import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { MUX_PROTOCOLS } from './outbound-form-constants';
export function isMuxAllowed(protocol: string, flow: string, network: string): boolean {
if (!MUX_PROTOCOLS.has(protocol)) return false;
if (protocol === 'vless' && flow) return false;
if (network === 'xhttp') return false;
return true;
}
// Per-network bootstrap. Mirrors the legacy class constructors so the
// initial state for each transport matches what xray-core expects.
export function newStreamSlice(network: string): Record<string, unknown> {
switch (network) {
case 'tcp':
return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
case 'kcp':
return {
network: 'kcp',
kcpSettings: {
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
cwndMultiplier: 1, maxSendingWindow: 2097152,
},
};
case 'ws':
return {
network: 'ws',
wsSettings: { path: '/', host: '', headers: {}, heartbeatPeriod: 0 },
};
case 'grpc':
return {
network: 'grpc',
grpcSettings: { serviceName: '', authority: '', multiMode: false },
};
case 'httpupgrade':
return {
network: 'httpupgrade',
httpupgradeSettings: { path: '/', host: '', headers: {} },
};
case 'xhttp':
return {
network: 'xhttp',
xhttpSettings: {
path: '/', host: '', mode: '', headers: [],
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
},
};
case 'hysteria':
return {
network: 'hysteria',
hysteriaSettings: {
version: 2,
auth: '',
udpIdleTimeout: 60,
},
};
default:
return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
}
}
// Hysteria2 always rides its own QUIC transport with TLS — the panel never
// offers another transport or 'none' security for it.
export function hysteriaStreamSlice(): Record<string, unknown> {
return {
...newStreamSlice('hysteria'),
security: 'tls',
tlsSettings: {
serverName: '', alpn: ['h3'], fingerprint: '',
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
},
};
}
export function buildAddModeValues(): OutboundFormValues {
return rawOutboundToFormValues({});
}
@@ -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;
}
@@ -0,0 +1,7 @@
export interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}
@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import { Form, Select } from 'antd';
export default function BlackholeFields() {
const { t } = useTranslation();
return (
<Form.Item label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
]}
/>
</Form.Item>
);
}
@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
<Select
allowClear
placeholder={t('pages.xray.outboundForm.unchanged')}
options={[
{ value: 'udp', label: 'udp' },
{ value: 'tcp', label: 'tcp' },
]}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.List name={['settings', 'rules']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => add({ action: 'direct', qtype: '', domain: '' })}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
onClick={() => remove(field.name)}
/>
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
<Select
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item label="QType" name={[field.name, 'qtype']}>
<Input placeholder="1,3,23-24" />
</Form.Item>
<Form.Item label={t('domainName')} name={[field.name, 'domain']}>
<Input placeholder="domain:example.com" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
);
}
@@ -0,0 +1,265 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function FreedomFields({ form }: { form: FormInstance<OutboundFormValues> }) {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
]}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
<Select
options={[
{ value: 0, label: `(${t('none')})` },
{ value: 1, label: 'v1' },
{ value: 2, label: 'v2' },
]}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.fragment')} shouldUpdate noStyle>
{() => {
const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
};
const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
return (
<>
<Form.Item label="Fragment">
<Switch
checked={enabled}
onChange={(checked) => {
form.setFieldValue(
['settings', 'fragment'],
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{enabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
>
<Select
options={[
{ value: '1-3', label: '1-3' },
{ value: 'tlshello', label: 'tlshello' },
]}
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.interval')}
name={['settings', 'fragment', 'interval']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.maxSplit')}
name={['settings', 'fragment', 'maxSplit']}
>
<Input />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['settings', 'noises']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={fields.length > 0}
onChange={(checked) => {
if (checked) {
add({
type: 'rand',
packet: '10-20',
delay: '10-16',
applyTo: 'ip',
});
} else {
// remove() with no arg is not supported;
// walk fields in reverse and drop each.
for (let i = fields.length - 1; i >= 0; i--) {
remove(fields[i].name);
}
}
}}
/>
{fields.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
onClick={() =>
add({
type: 'rand',
packet: '10-20',
delay: '10-16',
applyTo: 'ip',
})
}
/>
)}
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
onClick={() => remove(field.name)}
/>
)}
</div>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.type')} name={[field.name, 'type']}>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.packet')} name={[field.name, 'packet']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.delayMs')} name={[field.name, 'delay']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.settings.subFormats.applyTo')} name={[field.name, 'applyTo']}>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</div>
))}
</>
)}
</Form.List>
<Form.List name={['settings', 'finalRules']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() =>
add({
action: 'allow',
network: '',
port: '',
ip: [],
blockDelay: '',
})
}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
onClick={() => remove(field.name)}
/>
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.network')} name={[field.name, 'network']}>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.port')} name={[field.name, 'port']}>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</Form.Item>
<Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const ruleAction = form.getFieldValue([
'settings',
'finalRules',
field.name,
'action',
]);
if (ruleAction !== 'block') return null;
return (
<Form.Item
label={t('pages.xray.outboundForm.blockDelay')}
name={[field.name, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</Form.Item>
);
}}
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
);
}
@@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
export default function HttpFields() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('username')} name={['settings', 'user']}>
<Input />
</Form.Item>
<Form.Item label={t('password')} name={['settings', 'pass']}>
<Input />
</Form.Item>
</>
);
}
@@ -0,0 +1,12 @@
export { default as ServerTarget } from './server-target';
export { default as VmessFields } from './vmess';
export { default as VlessFields } from './vless';
export { default as TrojanFields } from './trojan';
export { default as ShadowsocksFields } from './shadowsocks';
export { default as HttpFields } from './http';
export { default as SocksFields } from './socks';
export { default as WireguardFields } from './wireguard';
export { default as FreedomFields } from './freedom';
export { default as LoopbackFields } from './loopback';
export { default as BlackholeFields } from './blackhole';
export { default as DnsFields } from './dns';
@@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
export default function LoopbackFields() {
const { t } = useTranslation();
return (
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
</Form.Item>
);
}
@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber } from 'antd';
export default function ServerTarget() {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('pages.inbounds.address')}
name={['settings', 'address']}
rules={[{ required: true, message: t('pages.xray.outboundForm.addressRequired') }]}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.port')}
name={['settings', 'port']}
rules={[{ required: true, message: t('pages.xray.outboundForm.portRequired') }]}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</>
);
}
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import { antdRule } from '@/utils/zodForm';
import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
export default function ShadowsocksFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('password')}
name={['settings', 'password']}
rules={[antdRule(ShadowsocksOutboundFormSettingsSchema.shape.password, t)]}
>
<Input />
</Form.Item>
<Form.Item
label={t('encryption')}
name={['settings', 'method']}
rules={[antdRule(SSMethodSchema, t)]}
>
<Select options={SS_METHOD_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.udpOverTcp')}
name={['settings', 'uot']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
<InputNumber min={1} max={2} />
</Form.Item>
</>
);
}
@@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
export default function SocksFields() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('username')} name={['settings', 'user']}>
<Input />
</Form.Item>
<Form.Item label={t('password')} name={['settings', 'pass']}>
<Input />
</Form.Item>
</>
);
}
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function TrojanFields() {
const { t } = useTranslation();
return (
<Form.Item
label={t('password')}
name={['settings', 'password']}
rules={[antdRule(TrojanOutboundFormSettingsSchema.shape.password, t)]}
>
<Input />
</Form.Item>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import {
VlessOutboundFormSettingsSchema,
VmessOutboundFormSettingsSchema,
} from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
export default function VlessFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
label="ID"
name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
>
<Input placeholder="UUID" />
</Form.Item>
<Form.Item
label={t('encryption')}
name={['settings', 'encryption']}
rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]}
>
<Input />
</Form.Item>
<Form.Item label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
<Input placeholder={t('pages.xray.outboundForm.optional')} />
</Form.Item>
</>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { antdRule } from '@/utils/zodForm';
import { SECURITY_OPTIONS } from '../outbound-form-constants';
export default function VmessFields() {
const { t } = useTranslation();
return (
<>
<Form.Item
label="ID"
name={['settings', 'id']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
>
<Input placeholder="UUID" />
</Form.Item>
<Form.Item
label={t('security')}
name={['settings', 'security']}
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]}
>
<Select options={SECURITY_OPTIONS} />
</Form.Item>
</>
);
}
@@ -0,0 +1,142 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
import { InputAddon } from '@/components/ui';
import { WireguardDomainStrategy } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function WireguardFields({ form }: { form: FormInstance<OutboundFormValues> }) {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
<Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
</Form.Item>
<Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => {
const pair = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
}}
/>
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
<Input disabled />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
]}
/>
</Form.Item>
<Form.Item label="MTU" name={['settings', 'mtu']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.workers')} name={['settings', 'workers']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.info.noKernelTun')}
name={['settings', 'noKernelTun']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
<Input placeholder="comma-separated bytes, e.g. 1,2,3" />
</Form.Item>
<Form.List name={['settings', 'peers']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() =>
add({
publicKey: '',
psk: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 0,
})
}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.key}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
onClick={() => remove(field.name)}
/>
)}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.endpoint')} name={[field.name, 'endpoint']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.publicKey')}
name={[field.name, 'publicKey']}
>
<Input />
</Form.Item>
<Form.Item label="PSK" name={[field.name, 'psk']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<>
{ipFields.map((ipField, ipIdx) => (
<Space.Compact
key={ipField.key}
block
style={{ marginBottom: 4 }}
>
<Form.Item noStyle name={ipField.name}>
<Input />
</Form.Item>
{ipFields.length > 1 && (
<InputAddon onClick={() => removeIp(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => addIp('')}
/>
</>
)}
</Form.List>
</Form.Item>
<Form.Item label={t('pages.inbounds.info.keepAlive')} name={[field.name, 'keepAlive']}>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
);
}
@@ -0,0 +1,2 @@
export { default as TlsForm } from './tls';
export { default as RealityForm } from './reality';
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { UTLS_OPTIONS } from '../outbound-form-constants';
export default function RealityForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
label="SNI"
name={['streamSettings', 'realitySettings', 'serverName']}
>
<Input />
</Form.Item>
<Form.Item
label="uTLS"
name={['streamSettings', 'realitySettings', 'fingerprint']}
>
<Select options={UTLS_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.shortId')}
name={['streamSettings', 'realitySettings', 'shortId']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.spiderX')}
name={['streamSettings', 'realitySettings', 'spiderX']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.publicKey')}
name={['streamSettings', 'realitySettings', 'publicKey']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.mldsa65Verify')}
name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</Form.Item>
</>
);
}
@@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Select } from 'antd';
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
export default function TlsForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
label="SNI"
name={['streamSettings', 'tlsSettings', 'serverName']}
>
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
</Form.Item>
<Form.Item
label="uTLS"
name={['streamSettings', 'tlsSettings', 'fingerprint']}
>
<Select
allowClear
placeholder={t('none')}
options={UTLS_OPTIONS}
/>
</Form.Item>
<Form.Item
label="ALPN"
name={['streamSettings', 'tlsSettings', 'alpn']}
>
<Select mode="multiple" options={ALPN_OPTIONS} />
</Form.Item>
<Form.Item
label="ECH"
name={['streamSettings', 'tlsSettings', 'echConfigList']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.verifyPeerName')}
name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
>
<Input placeholder="cloudflare-dns.com" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.pinnedSha256')}
name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
>
<Input placeholder="base64 SHA256" />
</Form.Item>
</>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd';
export default function GrpcForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('pages.inbounds.form.serviceName')}
name={['streamSettings', 'grpcSettings', 'serviceName']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.authority')}
name={['streamSettings', 'grpcSettings', 'authority']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.multiMode')}
name={['streamSettings', 'grpcSettings', 'multiMode']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
export default function HttpUpgradeForm() {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('host')}
name={['streamSettings', 'httpupgradeSettings', 'host']}
>
<Input />
</Form.Item>
<Form.Item
label={t('path')}
name={['streamSettings', 'httpupgradeSettings', 'path']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'httpupgradeSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
);
}
@@ -0,0 +1,134 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { HeaderMapEditor } from '@/components/form';
const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
export default function HysteriaForm({ form }: { form: FormInstance }) {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']}
>
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.authPassword')}
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.masquerade')}>
<Form.Item shouldUpdate noStyle>
{() => {
const m = form.getFieldValue(MASQ_PATH);
return (
<Switch
checked={!!m}
onChange={(checked) =>
form.setFieldValue(
MASQ_PATH,
checked
? {
type: '', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
: undefined,
)
}
/>
);
}}
</Form.Item>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
if (!m) return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.type')}
name={[...MASQ_PATH, 'type']}
>
<Select
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
]}
/>
</Form.Item>
{m.type === 'proxy' && (
<>
<Form.Item
label={t('pages.inbounds.form.upstreamUrl')}
name={[...MASQ_PATH, 'url']}
>
<Input placeholder="https://www.example.com" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ_PATH, 'rewriteHost']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ_PATH, 'insecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{m.type === 'file' && (
<Form.Item
label={t('pages.inbounds.form.directory')}
name={[...MASQ_PATH, 'dir']}
>
<Input placeholder="/var/www/html" />
</Form.Item>
)}
{m.type === 'string' && (
<>
<Form.Item
label={t('pages.inbounds.form.statusCode')}
name={[...MASQ_PATH, 'statusCode']}
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.body')}
name={[...MASQ_PATH, 'content']}
>
<Input.TextArea autoSize={{ minRows: 3 }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={[...MASQ_PATH, 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
</>
);
}
@@ -0,0 +1,9 @@
export { default as RawForm } from './raw';
export { default as KcpForm } from './kcp';
export { default as WsForm } from './ws';
export { default as GrpcForm } from './grpc';
export { default as HttpUpgradeForm } from './httpupgrade';
export { default as XhttpForm } from './xhttp';
export { default as HysteriaForm } from './hysteria';
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Form, InputNumber } from 'antd';
export default function KcpForm() {
const { t } = useTranslation();
return (
<>
<Form.Item label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.uplinkMbps')}
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.downlinkMbps')}
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.cwndMultiplier')}
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.maxSendingWindow')}
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
>
<InputNumber min={0} />
</Form.Item>
</>
);
}
@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next';
import { Form, InputNumber, Select, Switch, type FormInstance } from 'antd';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { isMuxAllowed } from '../outbound-form-helpers';
interface MuxFormProps {
form: FormInstance<OutboundFormValues>;
protocol: string;
network: string;
}
export default function MuxForm({ form, protocol, network }: MuxFormProps) {
const { t } = useTranslation();
const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
if (!isMuxAllowed(protocol, flow, network)) return null;
return (
<Form.Item shouldUpdate noStyle>
{() => {
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
return (
<>
<Form.Item
label={t('pages.settings.mux')}
name={['mux', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
{muxEnabled && (
<>
<Form.Item
label={t('pages.settings.subFormats.concurrency')}
name={['mux', 'concurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpConcurrency')}
name={['mux', 'xudpConcurrency']}
>
<InputNumber min={-1} max={1024} />
</Form.Item>
<Form.Item
label={t('pages.settings.subFormats.xudpUdp443')}
name={['mux', 'xudpProxyUDP443']}
>
<Select
options={['reject', 'allow', 'skip'].map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
);
}
@@ -0,0 +1,121 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch, type FormInstance } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
export default function RawForm({ form }: { form: FormInstance<OutboundFormValues> }) {
const { t } = useTranslation();
return (
<Form.Item shouldUpdate noStyle>
{() => {
const type =
form.getFieldValue([
'streamSettings',
'tcpSettings',
'header',
'type',
]) ?? 'none';
return (
<>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Switch
checked={type === 'http'}
onChange={(checked) =>
form.setFieldValue(
['streamSettings', 'tcpSettings', 'header'],
checked
? {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
: { type: 'none' },
)
}
/>
</Form.Item>
{type === 'http' && (
<>
<Form.Item
label={t('pages.inbounds.form.requestMethod')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'method',
]}
>
<Input placeholder="GET" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestVersion')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'version',
]}
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestHeaders')}
name={[
'streamSettings', 'tcpSettings', 'header',
'request', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseVersion')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'version',
]}
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseStatus')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'status',
]}
>
<Input placeholder="200" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseReason')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'reason',
]}
>
<Input placeholder="OK" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.responseHeaders')}
name={[
'streamSettings', 'tcpSettings', 'header',
'response', 'headers',
]}
>
<HeaderMapEditor mode="v2" />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
);
}
@@ -0,0 +1,276 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
export default function SockoptForm({ form }: { form: FormInstance<OutboundFormValues> }) {
const { t } = useTranslation();
return (
<Form.Item shouldUpdate noStyle>
{() => {
const hasSockopt = !!form.getFieldValue([
'streamSettings',
'sockopt',
]);
return (
<>
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
<Switch
checked={hasSockopt}
onChange={(checked) => {
form.setFieldValue(
['streamSettings', 'sockopt'],
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasSockopt && (
<>
<Form.Item
label={t('pages.inbounds.form.dialerProxy')}
name={['streamSettings', 'sockopt', 'dialerProxy']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.xray.wireguard.domainStrategy')}
name={['streamSettings', 'sockopt', 'domainStrategy']}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.addressPortStrategy')}
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
>
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.keepAliveInterval')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpFastOpen')}
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.multipathTcp')}
name={['streamSettings', 'sockopt', 'tcpMptcp']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.penetrate')}
name={['streamSettings', 'sockopt', 'penetrate']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.markFwmark')}
name={['streamSettings', 'sockopt', 'mark']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.interface')}
name={['streamSettings', 'sockopt', 'interfaceName']}
>
<Input />
</Form.Item>
<Form.Item
label="TProxy"
name={['streamSettings', 'sockopt', 'tproxy']}
>
<Select
options={[
{ value: 'off', label: 'off' },
{ value: 'redirect', label: 'redirect' },
{ value: 'tproxy', label: 'tproxy' },
]}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpCongestion')}
name={['streamSettings', 'sockopt', 'tcpcongestion']}
>
<Select
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
value: v,
label: v,
}))}
/>
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.ipv6Only')}
name={['streamSettings', 'sockopt', 'V6Only']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.acceptProxyProtocol')}
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpMaxSeg')}
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.trustedXForwardedFor')}
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="trusted-proxy.example,10.0.0.0/8"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const he = form.getFieldValue([
'streamSettings', 'sockopt', 'happyEyeballs',
]);
const hasHe = he != null;
return (
<>
<Form.Item label="Happy Eyeballs">
<Switch
checked={hasHe}
onChange={(v) => {
form.setFieldValue(
['streamSettings', 'sockopt', 'happyEyeballs'],
v ? HappyEyeballsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasHe && (
<>
<Form.Item
label={t('pages.inbounds.form.tryDelayMs')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 (disabled) — 250 recommended" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.prioritizeIPv6')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.interleave')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.maxConcurrentTry')}
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
<Button
type="dashed"
size="small"
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
>
+ {t('pages.inbounds.form.addCustomOption')}
</Button>
</Form.Item>
{fields.map((field) => (
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item name={[field.name, 'system']} noStyle>
<Select
placeholder="all"
allowClear
style={{ width: 100 }}
options={[
{ value: 'linux', label: 'linux' },
{ value: 'windows', label: 'windows' },
{ value: 'darwin', label: 'darwin' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'type']} noStyle>
<Select
style={{ width: 80 }}
options={[
{ value: 'int', label: 'int' },
{ value: 'str', label: 'str' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'level']} noStyle>
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
</Form.Item>
<Form.Item name={[field.name, 'opt']} noStyle>
<Input placeholder="opt (decimal)" style={{ width: 120 }} />
</Form.Item>
<Form.Item name={[field.name, 'value']} noStyle>
<Input placeholder="value" style={{ flex: 1 }} />
</Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space.Compact>
))}
</>
)}
</Form.List>
</>
)}
</>
);
}}
</Form.Item>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber } from 'antd';
import { HeaderMapEditor } from '@/components/form';
export default function WsForm() {
const { t } = useTranslation();
return (
<>
<Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
<Input />
</Form.Item>
<Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.heartbeatPeriod')}
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'wsSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
);
}
@@ -0,0 +1,355 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { MODE_OPTIONS } from '../outbound-form-constants';
interface XhttpFormProps {
form: FormInstance<OutboundFormValues>;
onXmuxToggle: (checked: boolean) => void;
}
export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
const { t } = useTranslation();
return (
<>
<Form.Item
label={t('host')}
name={['streamSettings', 'xhttpSettings', 'host']}
>
<Input />
</Form.Item>
<Form.Item
label={t('path')}
name={['streamSettings', 'xhttpSettings', 'path']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.info.mode')}
name={['streamSettings', 'xhttpSettings', 'mode']}
>
<Select options={MODE_OPTIONS} />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingBytes')}
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'xhttpSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
{/* Padding obfs sub-section: gated by a Switch.
When on, four extra knobs (key/header/placement/
method) tune how Xray injects random padding to
disguise the post body shape. */}
<Form.Item
label={t('pages.inbounds.form.paddingObfsMode')}
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const obfs = !!form.getFieldValue([
'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
]);
if (!obfs) return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.paddingKey')}
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
>
<Input placeholder="x_padding" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingHeader')}
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
>
<Input placeholder="X-Padding" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingPlacement')}
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.paddingMethod')}
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
>
<Select
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev?.streamSettings?.xhttpSettings?.mode !==
curr?.streamSettings?.xhttpSettings?.mode
}
>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
return (
<Form.Item
label={t('pages.inbounds.form.uplinkHttpMethod')}
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
>
<Select
placeholder="Default (POST)"
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
]}
/>
</Form.Item>
);
}}
</Form.Item>
{/* Session + sequence + uplinkData placements:
three orthogonal slots Xray uses to thread
request metadata through the transport
(path / header / cookie / query). Key field
only matters when placement is not 'path'. */}
<Form.Item
label={t('pages.inbounds.form.sessionPlacement')}
name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
>
<Select
placeholder="Default (path)"
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const placement = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'sessionPlacement',
]);
if (!placement || placement === 'path') return null;
return (
<Form.Item
label={t('pages.inbounds.form.sessionKey')}
name={['streamSettings', 'xhttpSettings', 'sessionKey']}
>
<Input placeholder="x_session" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.sequencePlacement')}
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
>
<Select
placeholder="Default (path)"
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const placement = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'seqPlacement',
]);
if (!placement || placement === 'path') return null;
return (
<Form.Item
label={t('pages.inbounds.form.sequenceKey')}
name={['streamSettings', 'xhttpSettings', 'seqKey']}
>
<Input placeholder="x_seq" />
</Form.Item>
);
}}
</Form.Item>
{/* Mode-conditional sub-sections. */}
<Form.Item shouldUpdate noStyle>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
if (mode !== 'packet-up') return null;
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.minUploadInterval')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
>
<Input placeholder="30" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
>
<Input placeholder="1000000" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.uplinkDataPlacement')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const place = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
]);
if (!place || place === 'body') return null;
return (
<>
<Form.Item
label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
>
<Input placeholder="x_data" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
>
<InputNumber
min={0}
placeholder="0 (unlimited)"
style={{ width: '100%' }}
/>
</Form.Item>
</>
);
}}
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const mode = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'mode',
]);
if (mode !== 'stream-up' && mode !== 'stream-one') return null;
return (
<Form.Item
label={t('pages.xray.outboundForm.noGrpcHeader')}
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
valuePropName="checked"
>
<Switch />
</Form.Item>
);
}}
</Form.Item>
{/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs
when off. */}
<Form.Item
label="XMUX"
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valuePropName="checked"
>
<Switch onChange={onXmuxToggle} />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
if (!form.getFieldValue([
'streamSettings', 'xhttpSettings', 'enableXmux',
])) return null;
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.maxConcurrency')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
>
<Input placeholder="16-32" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxConnections')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
>
<Input placeholder="0" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxReuseTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxRequestTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
>
<Input placeholder="600-900" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxReusableSecs')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
>
<Input placeholder="1800-3000" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.keepAlivePeriod')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
);
}}
</Form.Item>
</>
);
}
@@ -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],
);
}
@@ -0,0 +1,2 @@
export { default as WarpModal } from './WarpModal';
export { default as NordModal } from './NordModal';
@@ -0,0 +1,17 @@
import { Tooltip } from 'antd';
import { csv } from './helpers';
export default function CriterionRow({ label, value, title }: { label: string; value?: string; title: string }) {
const parts = csv(value);
if (parts.length === 0) return null;
return (
<Tooltip title={title}>
<span className="criterion-row">
<span className="criterion-label">{label}</span>
<span className="criterion-value">{parts[0]}</span>
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
</span>
</Tooltip>
);
}
@@ -0,0 +1,291 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Modal, Space, Table } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import RuleCardList from './RuleCardList';
import { useRoutingColumns } from './useRoutingColumns';
import { arrJoin } from './helpers';
import type { RuleRow } from './types';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type { RuleObject } from '@/schemas/routing';
import './RoutingTab.css';
interface RoutingTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
isMobile: boolean;
}
export default function RoutingTab({
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
isMobile,
}: RoutingTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({
from: null,
to: null,
startY: 0,
moved: false,
});
const rules = useMemo(
() => (templateSettings?.routing?.rules || []) as RoutingRule[],
[templateSettings?.routing?.rules],
);
const rulesRef = useRef(rules);
rulesRef.current = rules;
const rows: RuleRow[] = useMemo(
() =>
rules.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.domain = arrJoin(rule.domain);
r.ip = arrJoin(rule.ip);
r.port = rule.port;
r.sourcePort = rule.sourcePort;
r.vlessRoute = rule.vlessRoute;
r.network = rule.network;
r.sourceIP = arrJoin(rule.sourceIP);
r.user = arrJoin(rule.user);
r.inboundTag = arrJoin(rule.inboundTag);
r.protocol = arrJoin(rule.protocol);
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
r.attrs = JSON.stringify(rule.attrs, null, 2);
}
r.outboundTag = rule.outboundTag;
r.balancerTag = rule.balancerTag;
return r;
}),
[rules],
);
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],
);
const inboundTagOptions = useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
const push = (tag?: string) => {
if (!tag || seen.has(tag)) return;
seen.add(tag);
out.push(tag);
};
for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag);
for (const tag of inboundTags || []) push(tag);
for (const ob of templateSettings?.outbounds || []) {
const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } };
push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag);
}
push((templateSettings?.dns as { tag?: string } | undefined)?.tag);
for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) {
if (typeof s === 'object' && s?.tag) push(s.tag);
}
return out;
}, [templateSettings, inboundTags]);
const outboundTagOptions = useMemo(() => {
const out = new Set<string>(['']);
for (const ob of templateSettings?.outbounds || []) {
if (ob?.tag) out.add(ob.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags]);
const balancerTagOptions = useMemo(() => {
const out: string[] = [''];
for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) {
if (b?.tag) out.push(b.tag);
}
return out;
}, [templateSettings?.routing?.balancers]);
function openAdd() {
setEditingRule(null);
setEditingIndex(null);
setRuleModalOpen(true);
}
function openEdit(idx: number) {
setEditingRule(rulesRef.current[idx]);
setEditingIndex(idx);
setRuleModalOpen(true);
}
function onRuleConfirm(rule: Record<string, unknown>) {
if (JSON.stringify(rule).length <= 3) {
setRuleModalOpen(false);
return;
}
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
const typed = rule as unknown as RuleObject;
if (editingIndex == null) tt.routing.rules.push(typed);
else tt.routing.rules[editingIndex] = typed;
});
setRuleModalOpen(false);
}
function confirmDelete(idx: number) {
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => {
tt.routing?.rules?.splice(idx, 1);
}),
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
});
}
function moveDown(idx: number) {
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || idx >= list.length - 1) return;
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
try {
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
} catch { /* ignore */ }
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
setDraggedIndex(idx);
setDropTargetIndex(idx);
const onMove = (e: PointerEvent) => {
const state = dragRef.current;
if (state.from == null) return;
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
state.moved = true;
const el = document.elementFromPoint(e.clientX, e.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const newIdx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(newIdx) && newIdx !== state.to) {
state.to = newIdx;
setDropTargetIndex(newIdx);
}
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
const { from, to, moved } = dragRef.current;
dragRef.current = { from: null, to: null, startY: 0, moved: false };
setDraggedIndex(null);
setDropTargetIndex(null);
if (!moved || from == null || to == null || from === to) return;
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [movedItem] = list.splice(from, 1);
list.splice(to, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
}
const desktopColumns = useRoutingColumns({
isMobile,
rowsLength: rows.length,
onHandlePointerDown,
openEdit,
moveUp,
moveDown,
confirmDelete,
});
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
scroll={{ x: 1150 }}
size="small"
className="routing-table"
onRow={(_record, index) => {
const classes: string[] = [];
const i = index ?? -1;
if (draggedIndex === i) classes.push('row-dragging');
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
}
return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes<HTMLElement>;
}}
/>
)}
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
</Space>
</>
);
}
@@ -0,0 +1,118 @@
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons';
import { chipPreview, ruleCriteriaChips } from './helpers';
import type { RuleRow } from './types';
interface RuleCardListProps {
rows: RuleRow[];
draggedIndex: number | null;
dropTargetIndex: number | null;
onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
openEdit: (idx: number) => void;
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
}
export default function RuleCardList({
rows,
draggedIndex,
dropTargetIndex,
onHandlePointerDown,
openEdit,
moveUp,
moveDown,
confirmDelete,
}: RuleCardListProps) {
const { t } = useTranslation();
return (
<div className="rule-list">
{rows.length === 0 ? (
<div className="rule-empty"></div>
) : (
rows.map((rule, index) => (
<div
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
data-row-key={index}
>
<div className="rule-card-head">
<HolderOutlined
className="drag-handle"
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/>
<span className="rule-number">#{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
<div className="rule-flow">
<div className="flow-side">
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
{rule.inboundTag ? (
<Tag color="blue" className="flow-tag">{chipPreview(rule.inboundTag)}</Tag>
) : (
<span className="criterion-empty">any</span>
)}
</div>
<span className="flow-arrow"></span>
<div className="flow-side flow-side-target">
<span className="flow-label">
{rule.balancerTag ? t('pages.xray.balancer') || 'Balancer' : t('pages.xray.Outbounds')}
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
<ExportOutlined /> {rule.outboundTag}
</Tag>
) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag">
<ClusterOutlined /> {rule.balancerTag}
</Tag>
) : (
<span className="criterion-empty"></span>
)}
</div>
</div>
{ruleCriteriaChips(rule).length > 0 && (
<div className="rule-criteria">
{ruleCriteriaChips(rule).map((chip) => (
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
<span className="criterion-chip">
<span className="criterion-chip-label">{chip.label}</span>
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
</span>
</Tooltip>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
import { InputAddon } from '@/components/ui';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
export interface RoutingRule {
@@ -0,0 +1,33 @@
import type { RuleRow } from './types';
export function arrJoin(v: unknown): string | undefined {
if (v == null) return undefined;
if (Array.isArray(v)) return v.join(',');
return String(v);
}
export function csv(value?: string): string[] {
if (!value) return [];
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
}
export function chipPreview(value?: string): string {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
export function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as RoutingTab } from './RoutingTab';
export { default as RuleFormModal } from './RuleFormModal';
+16
View File
@@ -0,0 +1,16 @@
export interface RuleRow {
key: number;
domain?: string;
ip?: string;
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string;
user?: string;
inboundTag?: string;
protocol?: string;
attrs?: string;
outboundTag?: string;
balancerTag?: string;
}
@@ -0,0 +1,170 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag } from 'antd';
import {
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import CriterionRow from './CriterionRow';
import type { RuleRow } from './types';
interface RoutingColumnsParams {
isMobile: boolean;
rowsLength: number;
onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
openEdit: (idx: number) => void;
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
}
export function useRoutingColumns({
isMobile,
rowsLength,
onHandlePointerDown,
openEdit,
moveUp,
moveDown,
confirmDelete,
}: RoutingColumnsParams): ColumnsType<RuleRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
align: 'center',
width: 100,
key: 'action',
render: (_v, _r, index) => (
<div className="action-cell">
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.xray.rules.source'),
align: 'left',
width: 180,
key: 'source',
render: (_v, record) => (
<div className="criterion-flow">
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
{record.sourcePort && <CriterionRow label="Port" value={record.sourcePort} title={`Source port: ${record.sourcePort}`} />}
{record.vlessRoute && <CriterionRow label="VLESS" value={record.vlessRoute} title={`VLESS route: ${record.vlessRoute}`} />}
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.inbounds.network'),
align: 'left',
width: 180,
key: 'network',
render: (_v, record) => (
<div className="criterion-flow">
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.rules.dest'),
align: 'left',
key: 'destination',
render: (_v, record) => (
<div className="criterion-flow">
{record.ip && <CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />}
{record.domain && <CriterionRow label="Domain" value={record.domain} title={`Domain: ${record.domain}`} />}
{record.port && <CriterionRow label="Port" value={record.port} title={`Destination port: ${record.port}`} />}
{!record.ip && !record.domain && !record.port && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.Inbounds'),
align: 'left',
width: 180,
key: 'inbound',
render: (_v, record) => (
<div className="criterion-flow">
{record.inboundTag && <CriterionRow label="Tag" value={record.inboundTag} title={`Inbound tag: ${record.inboundTag}`} />}
{record.user && <CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />}
{!record.inboundTag && !record.user && <span className="criterion-empty"></span>}
</div>
),
},
{
title: t('pages.xray.Outbounds'),
align: 'left',
width: 170,
key: 'outbound',
render: (_v, record) =>
record.outboundTag ? (
<div className="target-row">
<ExportOutlined className="target-icon" />
<Tag color="green">{record.outboundTag}</Tag>
</div>
) : (
<span className="criterion-empty"></span>
),
},
{
title: t('pages.xray.Balancers'),
align: 'left',
width: 150,
key: 'balancer',
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
<ClusterOutlined className="target-icon" />
<Tag color="purple">{record.balancerTag}</Tag>
</div>
) : (
<span className="criterion-empty"></span>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile, rowsLength],
);
}