feat(frontend): multi-node cloning initial implementation (#6216)

* feat(frontend): multinode cloning initial implementation

* fix(frontend): harden live node detection in multinode cloning

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(frontend): add aria label to clone inbound modal

* fix(frontend): shallow copy inbound settings during cloning

* fix(frontend): avoid potential port conflict during testing

* fix(frontend): selection buttons and websocket selection reset fix

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Lex Rivera
2026-08-15 18:36:25 +03:00
committed by GitHub
parent 03950b1295
commit 8c8556ab32
20 changed files with 597 additions and 46 deletions
+42 -34
View File
@@ -23,7 +23,8 @@ import {
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { buildClonePayload } from '@/lib/xray/inbound-clone';
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
@@ -40,6 +41,7 @@ import { useInbounds } from './useInbounds';
import { InboundList } from './list';
import { LazyMount } from '@/components/utility';
const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
const CloneInboundModal = lazy(() => import('./CloneInboundModal'));
const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
@@ -118,6 +120,20 @@ export default function InboundsPage() {
);
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
// Ports already bound per clone target (0 = local panel, matching the
// clients page node-filter sentinel), for the clone dialog's client-side
// conflict pre-check.
const clonePortsInUse = useMemo(() => {
const map = new Map<number, Set<number>>();
for (const ib of dbInbounds || []) {
const key = ib.nodeId ?? 0;
const ports = map.get(key) ?? new Set<number>();
ports.add(ib.port);
map.set(key, ports);
}
return map;
}, [dbInbounds]);
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
@@ -144,6 +160,9 @@ export default function InboundsPage() {
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
const [textOpen, setTextOpen] = useState(false);
const [textTitle, setTextTitle] = useState('');
const [textContent, setTextContent] = useState('');
@@ -429,48 +448,27 @@ export default function InboundsPage() {
}, [modal, refresh, t, clientCount]);
const confirmClone = useCallback((dbInbound: DBInbound) => {
// Node-eligible protocol with at least one deployable node → open the
// target picker; anything else keeps the original one-click local clone.
if (NODE_ELIGIBLE_PROTOCOLS[dbInbound.protocol] && (nodesList || []).some((n) => n.enable && n.status === 'online')) {
setCloneSource(dbInbound);
setCloneOpen(true);
return;
}
modal.confirm({
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
content: t('pages.inbounds.cloneConfirmContent'),
okText: t('pages.inbounds.clone'),
cancelText: t('cancel'),
onOk: async () => {
let clonedSettings: string;
try {
const raw = coerceInboundJsonField(dbInbound.settings);
raw.clients = [];
clonedSettings = JSON.stringify(raw);
} catch {
const fallback = createDefaultInboundSettings(dbInbound.protocol);
clonedSettings = fallback ? JSON.stringify(fallback, null, 2) : '{}';
}
const streamSettingsString = typeof dbInbound.streamSettings === 'string'
? dbInbound.streamSettings
: JSON.stringify(dbInbound.streamSettings ?? {});
const sniffingString = typeof dbInbound.sniffing === 'string'
? dbInbound.sniffing
: JSON.stringify(dbInbound.sniffing ?? {});
const data = {
up: 0,
down: 0,
total: 0,
remark: `${dbInbound.remark} (clone)`,
enable: false,
expiryTime: 0,
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: dbInbound.protocol,
settings: clonedSettings,
streamSettings: streamSettingsString,
sniffing: sniffingString,
shareAddrStrategy: dbInbound.shareAddrStrategy,
shareAddr: dbInbound.shareAddr,
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
const msg = await HttpUtil.post(
'/panel/api/inbounds/add',
buildClonePayload(dbInbound, RandomUtil.randomInteger(10000, 60000), null),
);
if (msg?.success) await refresh();
},
});
}, [modal, refresh, t]);
}, [modal, nodesList, refresh, t]);
const onGeneralAction = useCallback((key: GeneralAction) => {
switch (key) {
@@ -709,6 +707,16 @@ export default function InboundsPage() {
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}>
<CloneInboundModal
open={cloneOpen}
onClose={() => setCloneOpen(false)}
onCloned={refresh}
dbInbound={cloneSource}
nodes={nodesList || []}
portsInUse={clonePortsInUse}
/>
</LazyMount>
<LazyMount when={textOpen}>
<TextModal