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
@@ -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>
</>
);
}
+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
@@ -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>