mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 01:25:00 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user