Files
3x-ui/frontend/src/pages/xray/routing/RoutingTab.tsx
T
Kuzz007 f70002d9a6 Revert "fix(routing): insert new rules after api instead of appending at the end"
This reverts commit 638458bd. Reordering new rules to the top by
creation-time was a blunt heuristic: it fixed a rule silently shadowed
by an unrestricted catch-all above it, but breaks the opposite,
equally valid setup -- e.g. a pre-existing "youtube -> interfaceA" rule
(no inboundTag restriction, meant to apply everywhere) followed by a
new "amneziawg -> interfaceB" rule. Under the reverted logic the newer,
broader-matching amneziawg rule would jump above the youtube rule and
shadow it for amneziawg-sourced traffic -- the exact same class of bug
this was meant to fix, just aimed the other way.

Xray's rule order encodes real admin intent (which rule should win for
overlapping traffic) that creation time can't safely stand in for.
Back to plain append-at-the-end; admins reorder manually with the
existing moveUp/moveDown controls, same as upstream's own behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:14:56 +03:00

440 lines
15 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import {
AimOutlined,
ControlOutlined,
ExportOutlined,
ImportOutlined,
MoreOutlined,
PlusOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import RoutingBasic from './RoutingBasic';
import RouteTester from './RouteTester';
import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal';
import RuleCardList from './RuleCardList';
import { useRoutingColumns } from './useRoutingColumns';
import { arrJoin, originalRuleIndex } 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[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
export default function RoutingTab({
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutboundTags,
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 rowsRef = useRef<RuleRow[]>([]);
const rows: RuleRow[] = useMemo(
() =>
rules
.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.enabled = rule.enabled !== false;
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;
})
.filter((r) => {
const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
return !inboundTags.some(isBalancerLoopbackTag);
}),
[rules],
);
rowsRef.current = rows;
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) || isBalancerLoopbackTag(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);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
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]);
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [exportContent, setExportContent] = useState('');
function exportRules() {
setExportContent(JSON.stringify(rules, null, 2));
setExportOpen(true);
}
function importRules(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
message.error(t('pages.xray.importInvalidJson'));
return;
}
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
const list = Array.isArray(parsed)
? parsed
: Array.isArray(obj?.rules)
? obj.rules
: Array.isArray(obj?.routing?.rules)
? obj.routing!.rules
: null;
if (!list) {
message.error(t('pages.xray.importInvalidJson'));
return;
}
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
tt.routing.rules.push(...(list as RuleObject[]));
});
setImportOpen(false);
}
function openAdd() {
setEditingRule(null);
setEditingIndex(null);
setRuleModalOpen(true);
}
function openEdit(idx: number) {
const target = originalRuleIndex(rowsRef.current, idx);
setEditingRule(rulesRef.current[target]);
setEditingIndex(target);
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) {
const target = originalRuleIndex(rowsRef.current, idx);
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(target, 1);
}),
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
const target = originalRuleIndex(rowsRef.current, idx);
const prev = originalRuleIndex(rowsRef.current, idx - 1);
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || !list[target] || !list[prev]) return;
[list[prev], list[target]] = [list[target], list[prev]];
});
}
function moveDown(idx: number) {
if (idx >= rowsRef.current.length - 1) return;
const target = originalRuleIndex(rowsRef.current, idx);
const next = originalRuleIndex(rowsRef.current, idx + 1);
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || !list[target] || !list[next]) return;
[list[next], list[target]] = [list[target], list[next]];
});
}
function toggleRule(idx: number, enabled: boolean) {
const target = originalRuleIndex(rowsRef.current, idx);
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || !list[target]) return;
list[target].enabled = enabled;
});
}
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;
const fromOrig = originalRuleIndex(rowsRef.current, from);
const toOrig = originalRuleIndex(rowsRef.current, to);
mutate((tt) => {
const list = tt.routing?.rules;
if (!list) return;
const [movedItem] = list.splice(fromOrig, 1);
list.splice(toOrig, 0, movedItem);
});
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
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,
moveDown,
confirmDelete,
toggleRule,
});
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}
<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%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importRules'), onClick: () => setImportOpen(true) },
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportRules'), disabled: rules.length === 0, onClick: exportRules },
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<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>
),
},
{
key: 'tester',
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
},
]}
/>
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importRules')}
okText={t('pages.xray.importRules')}
type="textarea"
json
onConfirm={importRules}
/>
<TextModal
open={exportOpen}
onClose={() => setExportOpen(false)}
title={t('pages.xray.exportRules')}
content={exportContent}
fileName="routing-rules.json"
json
/>
</>
);
}