mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +00: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:
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Typography, message } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { SelectAllClearButtons } from '@/components/form';
|
||||
import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
|
||||
// 0 is the "local panel" sentinel (inbounds without a nodeId) — the same
|
||||
// convention as the clients page node filter (#4997).
|
||||
const LOCAL_PANEL = 0;
|
||||
|
||||
interface CloneInboundModalProps {
|
||||
open: boolean;
|
||||
dbInbound: DBInbound | null;
|
||||
nodes: NodeRecord[];
|
||||
portsInUse: Map<number, Set<number>>;
|
||||
onClose: () => void;
|
||||
onCloned: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function CloneInboundModal({
|
||||
open,
|
||||
dbInbound,
|
||||
nodes,
|
||||
portsInUse,
|
||||
onClose,
|
||||
onCloned,
|
||||
}: CloneInboundModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [targets, setTargets] = useState<number[]>([LOCAL_PANEL]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const targetOptions = useMemo(() => [
|
||||
{ value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false },
|
||||
...(nodes || []).filter((n) => n.enable).map((n) => ({
|
||||
value: n.id,
|
||||
// Only online nodes are deployable targets: nodes report `unknown`
|
||||
// until their first heartbeat, and the backend refuses any status
|
||||
// other than online.
|
||||
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
|
||||
disabled: n.status !== 'online',
|
||||
})),
|
||||
], [nodes, t]);
|
||||
|
||||
// "Select all" must not pick targets the user can't pick manually —
|
||||
// offline nodes are disabled options in the dropdown.
|
||||
const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]);
|
||||
|
||||
// Reset the selection when the dialog OPENS: pre-select the source
|
||||
// inbound's own node when it is a selectable target, otherwise the local
|
||||
// panel (the only destination the clone action had before this picker).
|
||||
// Deps are deliberately `[open]` only — `nodes` gets a new identity on every
|
||||
// background refetch (heartbeats bump latency/status), and keying the reset
|
||||
// on it would clobber the user's selection mid-dialog.
|
||||
useEffect(() => {
|
||||
if (!open || !dbInbound) return;
|
||||
const src = dbInbound.nodeId ?? LOCAL_PANEL;
|
||||
const srcNode = (nodes || []).find((n) => n.id === src);
|
||||
const selectable = !!srcNode && !!srcNode.enable && srcNode.status === 'online';
|
||||
setTargets([selectable ? src : LOCAL_PANEL]);
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [open]);
|
||||
|
||||
async function submit() {
|
||||
if (!dbInbound || targets.length === 0) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Sequential posts keep per-target results in selection order; every
|
||||
// target gets its own fresh port because ports are only node-scoped.
|
||||
const results: { ok: boolean; reason: string }[] = [];
|
||||
for (const target of targets) {
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/inbounds/add',
|
||||
buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target),
|
||||
{ silent: true },
|
||||
);
|
||||
results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') });
|
||||
}
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - okCount;
|
||||
if (failed === 0) {
|
||||
messageApi.success(okCount === 1
|
||||
? t('pages.inbounds.toasts.inboundCreateSuccess')
|
||||
: t('pages.inbounds.toasts.clonedMany', { count: okCount }));
|
||||
} else {
|
||||
const firstError = results.find((r) => !r.ok)?.reason ?? '';
|
||||
const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed });
|
||||
messageApi.warning(firstError ? `${base} — ${firstError}` : base);
|
||||
}
|
||||
if (okCount > 0) await onCloned();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound?.remark ?? '' })}
|
||||
okText={t('pages.inbounds.clone')}
|
||||
cancelText={t('cancel')}
|
||||
okButtonProps={{ disabled: targets.length === 0, loading: submitting }}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.inbounds.cloneConfirmContent')}
|
||||
</Typography.Paragraph>
|
||||
<SelectAllClearButtons
|
||||
options={selectableOptions}
|
||||
value={targets}
|
||||
onChange={setTargets}
|
||||
/>
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.deployTo')}
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={targets}
|
||||
onChange={setTargets}
|
||||
options={targetOptions}
|
||||
placeholder={t('pages.inbounds.deployTo')}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@ import { Protocols } from '@/schemas/primitives';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
|
||||
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||
import { NODE_ELIGIBLE_PROTOCOLS } from '@/lib/xray/node-protocols';
|
||||
import { VLESS_AUTH_LABEL_KEYS, vlessEncryptionAuthKind } from '@/lib/xray/vless-encryption';
|
||||
import { SniffingSchema } from '@/schemas/primitives/sniffing';
|
||||
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
|
||||
@@ -101,14 +102,6 @@ const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label:
|
||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
||||
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||
const SHARE_ADDR_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
|
||||
Protocols.VLESS,
|
||||
Protocols.VMESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
Protocols.WIREGUARD,
|
||||
]);
|
||||
|
||||
function isValidShareAddrInput(value: string): boolean {
|
||||
const v = value.trim();
|
||||
@@ -216,7 +209,7 @@ export default function InboundFormModal({
|
||||
|
||||
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
||||
const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
|
||||
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
|
||||
const isNodeEligible = !!NODE_ELIGIBLE_PROTOCOLS[protocol];
|
||||
/*
|
||||
* The `node` share-address strategy only means something when the inbound can
|
||||
* actually live on a node — otherwise the node address it would resolve to is
|
||||
@@ -434,7 +427,7 @@ export default function InboundFormModal({
|
||||
const next = getV('protocol') as string;
|
||||
const settings = createDefaultInboundSettings(next) ?? undefined;
|
||||
setV('settings', settings);
|
||||
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
|
||||
if (!NODE_ELIGIBLE_PROTOCOLS[next]) {
|
||||
setV('nodeId', null);
|
||||
}
|
||||
if (next === Protocols.HYSTERIA) {
|
||||
@@ -534,8 +527,10 @@ export default function InboundFormModal({
|
||||
allowClear
|
||||
options={selectableNodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: `${n.name}${n.status === 'offline' ? ' (offline)' : ''}`,
|
||||
disabled: n.status === 'offline',
|
||||
// Same rule as the clone target picker: only online is
|
||||
// deployable (`unknown` = no heartbeat yet).
|
||||
label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`,
|
||||
disabled: n.status !== 'online',
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
Reference in New Issue
Block a user