Explorar o código

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 <[email protected]>

* 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 <[email protected]>
Lex Rivera hai 4 horas
pai
achega
8c8556ab32

+ 66 - 0
frontend/src/lib/xray/inbound-clone.ts

@@ -0,0 +1,66 @@
+import { RandomUtil } from '@/utils';
+import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
+import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
+
+/*
+ * Payload for POST /panel/api/inbounds/add reproducing `dbInbound` as a
+ * staged copy: fresh port, empty client list (emails are unique panel-wide
+ * and UUIDs must not repeat across nodes), disabled, no tag (the backend
+ * regenerates one with the correct per-node prefix), cleared listen (listen
+ * addresses are node-local). `nodeId === null` targets the local panel; the
+ * field is omitted from the wire payload then, matching the add-form adapter.
+ */
+export function buildClonePayload(dbInbound: DBInbound, port: number, nodeId: number | null) {
+  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 ?? {});
+  return {
+    up: 0,
+    down: 0,
+    total: 0,
+    remark: `${dbInbound.remark} (clone)`,
+    enable: false,
+    expiryTime: 0,
+    listen: '',
+    port,
+    protocol: dbInbound.protocol,
+    settings: clonedSettings,
+    streamSettings: streamSettingsString,
+    sniffing: sniffingString,
+    shareAddrStrategy: dbInbound.shareAddrStrategy,
+    shareAddr: dbInbound.shareAddr,
+    ...(nodeId != null ? { nodeId } : {}),
+  };
+}
+
+/*
+ * Random clone port in the add-form's range, avoiding ports already bound on
+ * the target node (client-side pre-check; the backend's node-scoped conflict
+ * check stays the final arbiter). A few random tries cover the common sparse
+ * case; a target so dense that those all miss falls back to a deterministic
+ * scan so a free port is always found when one exists.
+ */
+export function pickClonePort(used: Set<number> | undefined): number {
+  let port = RandomUtil.randomInteger(10000, 60000);
+  if (!used) return port;
+  for (let attempts = 0; attempts < 20 && used.has(port); attempts++) {
+    port = RandomUtil.randomInteger(10000, 60000);
+  }
+  if (used.has(port)) {
+    for (port = 10000; port <= 60000 && used.has(port); port++) { /* dense-range scan */ }
+    if (port > 60000) port = RandomUtil.randomInteger(10000, 60000);
+  }
+  return port;
+}

+ 16 - 0
frontend/src/lib/xray/node-protocols.ts

@@ -0,0 +1,16 @@
+import { Protocols } from '@/schemas/primitives';
+
+/*
+ * Protocols whose inbounds can live on a sub-node (the "Deploy To" set).
+ * Everything else (http, mixed, tunnel, tun, mtproto) is panel-local only.
+ * Shared by the inbound form's Deploy To selector and the clone dialog's
+ * target picker so the two surfaces can never drift apart.
+ */
+export const NODE_ELIGIBLE_PROTOCOLS: Readonly<Record<string, true>> = {
+  [Protocols.VLESS]: true,
+  [Protocols.VMESS]: true,
+  [Protocols.TROJAN]: true,
+  [Protocols.SHADOWSOCKS]: true,
+  [Protocols.HYSTERIA]: true,
+  [Protocols.WIREGUARD]: true,
+};

+ 136 - 0
frontend/src/pages/inbounds/CloneInboundModal.tsx

@@ -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
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -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

+ 7 - 12
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -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>

+ 219 - 0
frontend/src/test/clone-inbound-modal.test.tsx

@@ -0,0 +1,219 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+import CloneInboundModal from '@/pages/inbounds/CloneInboundModal';
+import { HttpUtil } from '@/utils';
+import { DBInbound } from '@/models/dbinbound';
+import { ThemeProvider } from '@/hooks/useTheme';
+import type { NodeRecord } from '@/api/queries/useNodesQuery';
+
+import { renderWithProviders } from './test-utils';
+
+const postSpy = vi.mocked(HttpUtil.post);
+
+const NODES = [
+  { id: 2, name: 'arm2', enable: true, status: 'online' },
+  { id: 3, name: 'arm3', enable: true, status: 'offline' },
+  { id: 4, name: 'retired', enable: false, status: 'online' },
+  { id: 5, name: 'arm5', enable: true, status: 'unknown' },
+] as unknown as NodeRecord[];
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({ clients: [{ id: 'uuid-1', email: 'a@test' }], decryption: 'none' }),
+    streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
+    sniffing: '',
+    nodeId: 2,
+    shareAddrStrategy: 'node',
+    shareAddr: '',
+  });
+}
+
+function renderModal(onCloned = vi.fn(), onClose = vi.fn()) {
+  renderWithProviders(
+    <CloneInboundModal
+      open
+      dbInbound={sourceInbound()}
+      nodes={NODES}
+      portsInUse={new Map([[2, new Set([443])]])}
+      onClose={onClose}
+      onCloned={onCloned}
+    />,
+  );
+  return { onCloned, onClose };
+}
+
+function openTargetDropdown() {
+  // antd v6 Select has no .ant-select-selector; mouseDown on the root opens it.
+  const selector = document.querySelector('.ant-select');
+  if (!selector) throw new Error('target select not rendered');
+  fireEvent.mouseDown(selector);
+}
+
+function clickOption(text: string) {
+  const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
+    .find((o) => (o.textContent ?? '').trim() === text);
+  if (!option) throw new Error(`option '${text}' not found`);
+  fireEvent.click(option);
+}
+
+function clickOk() {
+  fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
+}
+
+type PostBody = Record<string, unknown> & { nodeId?: number };
+const postedBodies = () => postSpy.mock.calls.map((c) => c[1] as PostBody);
+
+const selectedTitles = () => Array.from(document.querySelectorAll('.ant-select-selection-item[title]'))
+  .map((el) => el.getAttribute('title'));
+
+beforeEach(() => {
+  postSpy.mockClear();
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  postSpy.mockResolvedValue({ success: true, obj: {} } as any);
+});
+
+describe('CloneInboundModal', () => {
+  it('pre-selects the source node and clones onto it with a fresh port and no clients', async () => {
+    const { onCloned, onClose } = renderModal();
+
+    expect(document.querySelector('.ant-select-selection-item[title="arm2"]')).toBeTruthy();
+
+    clickOk();
+    await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(1));
+
+    expect(postSpy.mock.calls[0][0]).toBe('/panel/api/inbounds/add');
+    const body = postedBodies()[0];
+    expect(body.nodeId).toBe(2);
+    expect(body.enable).toBe(false);
+    expect(body.remark).toBe('edge (clone)');
+    expect(body.port).not.toBe(443);
+    expect(body).not.toHaveProperty('tag');
+    expect(JSON.parse(body.settings as string).clients).toEqual([]);
+
+    await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('posts once per selected target and omits nodeId for the local panel', async () => {
+    renderModal();
+    openTargetDropdown();
+    clickOption('Local panel');
+
+    clickOk();
+    await waitFor(() => expect(postSpy).toHaveBeenCalledTimes(2));
+
+    const [nodeBody, localBody] = postedBodies();
+    expect(nodeBody.nodeId).toBe(2);
+    expect(localBody).not.toHaveProperty('nodeId');
+    expect(nodeBody.port).not.toBe(443);
+  });
+
+  it('disables non-online nodes and hides disabled nodes from the target list', () => {
+    renderModal();
+    openTargetDropdown();
+
+    const option = (text: string) => Array.from(document.querySelectorAll('.ant-select-item-option'))
+      .find((o) => (o.textContent ?? '').trim() === text);
+    // Only `online` is selectable — `offline` and `unknown` (no heartbeat
+    // yet) are both shown but disabled.
+    expect(option('arm3 (offline)')?.className).toContain('ant-select-item-option-disabled');
+    expect(option('arm5 (unknown)')?.className).toContain('ant-select-item-option-disabled');
+    expect(option('arm2')?.className).not.toContain('ant-select-item-option-disabled');
+
+    const labels = Array.from(document.querySelectorAll('.ant-select-item-option'))
+      .map((o) => (o.textContent ?? '').trim());
+    expect(labels).toEqual(['Local panel', 'arm2', 'arm3 (offline)', 'arm5 (unknown)']);
+  });
+
+  it('select-all picks only selectable targets and clear-all blocks submit', () => {
+    renderModal();
+
+    const selectAll = screen.getByRole('button', { name: 'Select all' });
+    fireEvent.click(selectAll);
+
+    // Local panel + online node; offline/unknown nodes stay unpickable.
+    expect(selectedTitles().sort()).toEqual(['Local panel', 'arm2']);
+    expect((selectAll as HTMLButtonElement).disabled).toBe(true);
+
+    // Clear all empties the selection and disables OK.
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+    expect((screen.getByRole('button', { name: 'Clone' }) as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('keeps a cleared selection when the nodes list refetches mid-dialog', () => {
+    // The page LazyMounts the modal once and keeps it mounted; heartbeats give
+    // `nodes` a new array identity on every refetch. The reset effect must not
+    // refire on that — only on the open transition.
+    const modal = (nodes: NodeRecord[], open = true) => (
+      <ThemeProvider>
+        <CloneInboundModal
+          open={open}
+          dbInbound={sourceInbound()}
+          nodes={nodes}
+          portsInUse={new Map()}
+          onClose={() => {}}
+          onCloned={() => {}}
+        />
+      </ThemeProvider>
+    );
+    const { rerender } = render(modal(NODES));
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+
+    rerender(modal(NODES.map((n) => ({ ...n, latencyMs: 42 })) as unknown as NodeRecord[]));
+    expect(selectedTitles()).toEqual([]);
+  });
+
+  it('resets the selection to the source node on each reopen', () => {
+    const modal = (open: boolean) => (
+      <ThemeProvider>
+        <CloneInboundModal
+          open={open}
+          dbInbound={sourceInbound()}
+          nodes={NODES}
+          portsInUse={new Map()}
+          onClose={() => {}}
+          onCloned={() => {}}
+        />
+      </ThemeProvider>
+    );
+    const { rerender } = render(modal(true));
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(selectedTitles()).toEqual([]);
+
+    rerender(modal(false));
+    rerender(modal(true));
+    expect(selectedTitles()).toEqual(['arm2']);
+  });
+
+  it('reports a partial failure with the backend reason and still closes', async () => {
+    const { onCloned, onClose } = renderModal();
+    postSpy.mockImplementation(async (_url, data) => {
+      const body = data as PostBody;
+      if (body.nodeId === 2) {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        return { success: false, msg: "port 23456 (tcp) already used by inbound 'x' (#1) on *" } as any;
+      }
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      return { success: true, obj: {} } as any;
+    });
+
+    openTargetDropdown();
+    clickOption('Local panel');
+    clickOk();
+
+    await screen.findByText(/port 23456 \(tcp\) already used/);
+    await waitFor(() => expect(onCloned).toHaveBeenCalledTimes(1));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 85 - 0
frontend/src/test/inbound-clone.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildClonePayload, pickClonePort } from '@/lib/xray/inbound-clone';
+import { DBInbound } from '@/models/dbinbound';
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '0.0.0.0',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({
+      clients: [{ id: 'uuid-1', email: 'a@test', flow: 'xtls-rprx-vision' }],
+      decryption: 'none',
+    }),
+    streamSettings: { network: 'tcp', security: 'reality', realitySettings: { dest: 'www.lovelive-anime.jp:443' } },
+    sniffing: { enabled: true },
+    nodeId: 2,
+    shareAddrStrategy: 'node',
+    shareAddr: '',
+  });
+}
+
+describe('buildClonePayload', () => {
+  it('omits nodeId for a local-panel target so the row stays panel-local', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, null);
+    expect(payload).not.toHaveProperty('nodeId');
+  });
+
+  it('carries nodeId for a node target', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, 5);
+    expect(payload.nodeId).toBe(5);
+  });
+
+  it('stages the clone disabled with cleared clients, fresh port, and no tag', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, 3);
+    expect(payload.enable).toBe(false);
+    expect(payload.port).toBe(23456);
+    expect(payload.listen).toBe('');
+    expect(payload).not.toHaveProperty('tag');
+    expect(payload.remark).toBe('edge (clone)');
+
+    const settings = JSON.parse(payload.settings);
+    // Clients are dropped (emails are unique panel-wide, UUIDs must not
+    // repeat across nodes) while the rest of the settings survive verbatim.
+    expect(settings.clients).toEqual([]);
+    expect(settings.decryption).toBe('none');
+  });
+
+  it('stringifies object-shaped streamSettings and sniffing from hydrated rows', () => {
+    const payload = buildClonePayload(sourceInbound(), 23456, null);
+    expect(JSON.parse(payload.streamSettings)).toEqual({
+      network: 'tcp',
+      security: 'reality',
+      realitySettings: { dest: 'www.lovelive-anime.jp:443' },
+    });
+    expect(JSON.parse(payload.sniffing)).toEqual({ enabled: true });
+  });
+
+  it('survives malformed settings JSON with an empty client list fallback', () => {
+    const broken = sourceInbound();
+    broken.settings = '{not json';
+    const payload = buildClonePayload(broken, 23456, null);
+    const settings = JSON.parse(payload.settings);
+    expect(settings.clients ?? []).toEqual([]);
+  });
+});
+
+describe('pickClonePort', () => {
+  it('never returns a port already bound on the target', () => {
+    const used = new Set<number>();
+    for (let p = 10000; p <= 60000; p++) if (p !== 23456) used.add(p);
+    expect(pickClonePort(used)).toBe(23456);
+  });
+
+  it('stops probing when the range looks exhausted instead of spinning', () => {
+    const used = new Set<number>();
+    for (let p = 10000; p <= 60000; p++) used.add(p);
+    const port = pickClonePort(used);
+    expect(port).toBeGreaterThanOrEqual(10000);
+    expect(port).toBeLessThanOrEqual(60000);
+  });
+});

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "تم إنشاء الوارد بنجاح",
         "bulkDeleted": "تم حذف {count} إدخال",
         "bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
+        "clonedMany": "تم استنساخ {count} إدخال",
+        "clonedMixed": "تم استنساخ {ok}, وفشل {failed}",
         "inboundDeleteSuccess": "تم حذف الوارد بنجاح",
         "inboundClientAddSuccess": "تمت إضافة عميل(عملاء) وارد",
         "inboundClientDeleteSuccess": "تم حذف عميل وارد",

+ 2 - 0
internal/web/translation/en-US.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Inbound has been successfully created.",
         "bulkDeleted": "{count} inbounds deleted",
         "bulkDeletedMixed": "{ok} deleted, {failed} failed",
+        "clonedMany": "{count} inbounds cloned",
+        "clonedMixed": "{ok} cloned, {failed} failed",
         "inboundDeleteSuccess": "Inbound has been successfully deleted.",
         "inboundClientAddSuccess": "Inbound client(s) have been added.",
         "inboundClientDeleteSuccess": "Inbound client has been deleted.",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Entrada creada correctamente",
         "bulkDeleted": "{count} inbounds eliminados",
         "bulkDeletedMixed": "{ok} eliminados, {failed} fallidos",
+        "clonedMany": "{count} inbounds clonados",
+        "clonedMixed": "{ok} clonados, {failed} fallidos",
         "inboundDeleteSuccess": "Entrada eliminada correctamente",
         "inboundClientAddSuccess": "Cliente(s) de entrada añadido(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada eliminado",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "ورودی با موفقیت ایجاد شد",
         "bulkDeleted": "{count} اینباند حذف شد",
         "bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
+        "clonedMany": "{count} اینباند کلون شد",
+        "clonedMixed": "{ok} کلون، {failed} ناموفق",
         "inboundDeleteSuccess": "ورودی با موفقیت حذف شد",
         "inboundClientAddSuccess": "کلاینت(های) ورودی اضافه شدند",
         "inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Inbound berhasil dibuat",
         "bulkDeleted": "{count} inbound dihapus",
         "bulkDeletedMixed": "{ok} dihapus, {failed} gagal",
+        "clonedMany": "{count} inbound digandakan",
+        "clonedMixed": "{ok} digandakan, {failed} gagal",
         "inboundDeleteSuccess": "Inbound berhasil dihapus",
         "inboundClientAddSuccess": "Klien inbound telah ditambahkan",
         "inboundClientDeleteSuccess": "Klien inbound telah dihapus",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "インバウンドが正常に作成されました",
         "bulkDeleted": "{count} 件のインバウンドを削除しました",
         "bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
+        "clonedMany": "{count} 件のインバウンドを複製しました",
+        "clonedMixed": "{ok} 件複製、{failed} 件失敗",
         "inboundDeleteSuccess": "インバウンドが正常に削除されました",
         "inboundClientAddSuccess": "インバウンドクライアントが追加されました",
         "inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Entrada criada com sucesso",
         "bulkDeleted": "{count} inbounds excluídos",
         "bulkDeletedMixed": "{ok} excluídos, {failed} com falha",
+        "clonedMany": "{count} inbounds clonados",
+        "clonedMixed": "{ok} clonados, {failed} com falha",
         "inboundDeleteSuccess": "Entrada excluída com sucesso",
         "inboundClientAddSuccess": "Cliente(s) de entrada adicionado(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada excluído",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Подключение успешно создано",
         "bulkDeleted": "Удалено подключений: {count}",
         "bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
+        "clonedMany": "Скопировано подключений: {count}",
+        "clonedMixed": "Скопировано: {ok}, не удалось: {failed}",
         "inboundDeleteSuccess": "Подключение успешно удалено",
         "inboundClientAddSuccess": "Клиент(ы) подключения добавлен(ы)",
         "inboundClientDeleteSuccess": "Клиент подключения удалён",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Gelen bağlantı başarıyla oluşturuldu.",
         "bulkDeleted": "{count} gelen bağlantı silindi",
         "bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
+        "clonedMany": "{count} gelen bağlantı klonlandı",
+        "clonedMixed": "{ok} klonlandı, {failed} başarısız",
         "inboundDeleteSuccess": "Gelen bağlantı başarıyla silindi.",
         "inboundClientAddSuccess": "Gelen bağlantı kullanıcı(lar)ı eklendi.",
         "inboundClientDeleteSuccess": "Gelen bağlantı kullanıcısı silindi.",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Вхідне підключення успішно створено",
         "bulkDeleted": "Видалено підключень: {count}",
         "bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
+        "clonedMany": "Скопійовано підключень: {count}",
+        "clonedMixed": "Скопійовано: {ok}, не вдалось: {failed}",
         "inboundDeleteSuccess": "Вхідне підключення успішно видалено",
         "inboundClientAddSuccess": "Клієнт(и) вхідного підключення додано",
         "inboundClientDeleteSuccess": "Клієнта вхідного підключення видалено",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "Đã tạo thành công kết nối inbound",
         "bulkDeleted": "Đã xóa {count} inbound",
         "bulkDeletedMixed": "Đã xóa {ok}, thất bại {failed}",
+        "clonedMany": "Đã nhân bản {count} inbound",
+        "clonedMixed": "Đã nhân bản {ok}, thất bại {failed}",
         "inboundDeleteSuccess": "Đã xóa thành công kết nối inbound",
         "inboundClientAddSuccess": "Đã thêm client inbound",
         "inboundClientDeleteSuccess": "Đã xóa client inbound",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "入站连接已成功创建",
         "bulkDeleted": "已删除 {count} 个入站",
         "bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
+        "clonedMany": "已克隆 {count} 个入站",
+        "clonedMixed": "已克隆 {ok} 个,失败 {failed} 个",
         "inboundDeleteSuccess": "入站连接已成功删除",
         "inboundClientAddSuccess": "已添加入站客户端",
         "inboundClientDeleteSuccess": "入站客户端已删除",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -412,6 +412,8 @@
         "inboundCreateSuccess": "入站連接已成功建立",
         "bulkDeleted": "已刪除 {count} 個入站",
         "bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
+        "clonedMany": "已複製 {count} 個入站",
+        "clonedMixed": "已複製 {ok} 個,失敗 {failed} 個",
         "inboundDeleteSuccess": "入站連接已成功刪除",
         "inboundClientAddSuccess": "已新增入站客戶端",
         "inboundClientDeleteSuccess": "入站客戶端已刪除",