feat(xray): merge basic routing into the routing rules section

Move the basic routing presets (block torrent/IPs/domains, direct IPs/domains, IPv4) out of the Basics page into a Basic tab in the Routing section, next to the advanced Rules table; both edit the same routing.rules so existing rules stay in sync.

Drop the WARP and Nord routing preset rows - WARP/Nord outbounds are still added from the Outbounds page and any existing rules remain editable in the Rules tab.

Hide the Source and Balancers columns in the rules table when no rule populates them.
This commit is contained in:
MHSanaei
2026-06-03 09:57:45 +02:00
parent ac89ec724f
commit a4dae566ce
6 changed files with 252 additions and 247 deletions
@@ -0,0 +1,160 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Select, Switch } from 'antd';
import { SettingListItem } from '@/components/ui';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import {
BITTORRENT_PROTOCOLS,
BLOCK_DOMAINS_OPTIONS,
DOMAINS_OPTIONS,
IPS_OPTIONS,
SERVICES_OPTIONS,
directSettings,
ipv4Settings,
} from '../basics/constants';
import { ruleGetter, ruleSetter, syncOutbound } from '../basics/helpers';
interface RoutingBasicProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function RoutingBasic({ templateSettings, setTemplateSettings }: RoutingBasicProps) {
const { t } = useTranslation();
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 blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip');
const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain');
const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol');
const directIPs = ruleGetter(templateSettings, 'direct', 'ip');
const directDomains = ruleGetter(templateSettings, 'direct', 'domain');
const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain');
const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p));
return (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.blockConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.Torrent')}
paddings="small"
control={
<Switch
checked={torrentActive}
onChange={(checked) => mutate((tt) => {
const next = checked
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
ruleSetter(tt, 'blocked', 'protocol', next);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockips')}
paddings="small"
control={
<Select
mode="tags"
value={blockedIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockdomains')}
paddings="small"
control={
<Select
mode="tags"
value={blockedDomains}
style={{ width: '100%' }}
options={BLOCK_DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
/>
}
/>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.directConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.directips')}
paddings="small"
control={
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'direct', 'ip', v);
syncOutbound(tt, 'direct', directSettings);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.directdomains')}
paddings="small"
control={
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
options={DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'direct', 'domain', v);
syncOutbound(tt, 'direct', directSettings);
})}
/>
}
/>
<SettingListItem
title={t('pages.xray.ipv4Routing')}
description={t('pages.xray.ipv4RoutingDesc')}
paddings="small"
control={
<Select
mode="tags"
value={ipv4Domains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) => mutate((tt) => {
ruleSetter(tt, 'IPv4', 'domain', v);
syncOutbound(tt, 'IPv4', ipv4Settings);
})}
/>
}
/>
</>
);
}
@@ -231,3 +231,7 @@
opacity: 0.4;
}
.hint-alert {
text-align: center;
}
+80 -48
View File
@@ -1,8 +1,10 @@
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 { Button, Modal, Space, Table, Tabs } from 'antd';
import { ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import RoutingBasic from './RoutingBasic';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import RuleCardList from './RuleCardList';
@@ -226,9 +228,14 @@ export default function RoutingTab({
document.addEventListener('pointercancel', onUp);
}
const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
const hasBalancer = rows.some((r) => r.balancerTag);
const desktopColumns = useRoutingColumns({
isMobile,
rowsLength: rows.length,
showSource: hasSource,
showBalancer: hasBalancer,
onHandlePointerDown,
openEdit,
moveUp,
@@ -236,56 +243,81 @@ export default function RoutingTab({
confirmDelete,
});
const tableScrollX = desktopColumns.reduce((sum, c) => {
const col = c as { width?: number; hidden?: boolean };
return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0);
}, 0);
return (
<>
{modalContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
children: (
<RoutingBasic
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
/>
),
},
{
key: 'rules',
label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
children: (
<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>
{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: tableScrollX }}
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>;
}}
/>
)}
</Space>
),
},
]}
/>
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
</>
);
}
@@ -19,6 +19,8 @@ import type { RuleRow } from './types';
interface RoutingColumnsParams {
isMobile: boolean;
rowsLength: number;
showSource: boolean;
showBalancer: boolean;
onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
openEdit: (idx: number) => void;
moveUp: (idx: number) => void;
@@ -29,6 +31,8 @@ interface RoutingColumnsParams {
export function useRoutingColumns({
isMobile,
rowsLength,
showSource,
showBalancer,
onHandlePointerDown,
openEdit,
moveUp,
@@ -84,6 +88,7 @@ export function useRoutingColumns({
align: 'left',
width: 180,
key: 'source',
hidden: !showSource,
render: (_v, record) => (
<div className="criterion-flow">
{record.sourceIP && <CriterionRow label="IP" value={record.sourceIP} title={`Source IP: ${record.sourceIP}`} />}
@@ -110,6 +115,7 @@ export function useRoutingColumns({
{
title: t('pages.xray.rules.dest'),
align: 'left',
width: 200,
key: 'destination',
render: (_v, record) => (
<div className="criterion-flow">
@@ -153,6 +159,7 @@ export function useRoutingColumns({
align: 'left',
width: 150,
key: 'balancer',
hidden: !showBalancer,
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
@@ -165,6 +172,6 @@ export function useRoutingColumns({
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, isMobile, rowsLength],
[t, isMobile, rowsLength, showSource, showBalancer],
);
}