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
+66
View File
@@ -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
View File
@@ -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,
};
@@ -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>
@@ -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
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "تم إنشاء الوارد بنجاح",
"bulkDeleted": "تم حذف {count} إدخال",
"bulkDeletedMixed": "تم حذف {ok}, وفشل {failed}",
"clonedMany": "تم استنساخ {count} إدخال",
"clonedMixed": "تم استنساخ {ok}, وفشل {failed}",
"inboundDeleteSuccess": "تم حذف الوارد بنجاح",
"inboundClientAddSuccess": "تمت إضافة عميل(عملاء) وارد",
"inboundClientDeleteSuccess": "تم حذف عميل وارد",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "ورودی با موفقیت ایجاد شد",
"bulkDeleted": "{count} اینباند حذف شد",
"bulkDeletedMixed": "{ok} حذف، {failed} ناموفق",
"clonedMany": "{count} اینباند کلون شد",
"clonedMixed": "{ok} کلون، {failed} ناموفق",
"inboundDeleteSuccess": "ورودی با موفقیت حذف شد",
"inboundClientAddSuccess": "کلاینت(های) ورودی اضافه شدند",
"inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",
+2
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "インバウンドが正常に作成されました",
"bulkDeleted": "{count} 件のインバウンドを削除しました",
"bulkDeletedMixed": "{ok} 件削除、{failed} 件失敗",
"clonedMany": "{count} 件のインバウンドを複製しました",
"clonedMixed": "{ok} 件複製、{failed} 件失敗",
"inboundDeleteSuccess": "インバウンドが正常に削除されました",
"inboundClientAddSuccess": "インバウンドクライアントが追加されました",
"inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",
+2
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "Подключение успешно создано",
"bulkDeleted": "Удалено подключений: {count}",
"bulkDeletedMixed": "Удалено: {ok}, не удалось: {failed}",
"clonedMany": "Скопировано подключений: {count}",
"clonedMixed": "Скопировано: {ok}, не удалось: {failed}",
"inboundDeleteSuccess": "Подключение успешно удалено",
"inboundClientAddSuccess": "Клиент(ы) подключения добавлен(ы)",
"inboundClientDeleteSuccess": "Клиент подключения удалён",
+2
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "Вхідне підключення успішно створено",
"bulkDeleted": "Видалено підключень: {count}",
"bulkDeletedMixed": "Видалено: {ok}, не вдалось: {failed}",
"clonedMany": "Скопійовано підключень: {count}",
"clonedMixed": "Скопійовано: {ok}, не вдалось: {failed}",
"inboundDeleteSuccess": "Вхідне підключення успішно видалено",
"inboundClientAddSuccess": "Клієнт(и) вхідного підключення додано",
"inboundClientDeleteSuccess": "Клієнта вхідного підключення видалено",
+2
View File
@@ -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
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "入站连接已成功创建",
"bulkDeleted": "已删除 {count} 个入站",
"bulkDeletedMixed": "已删除 {ok} 个,失败 {failed} 个",
"clonedMany": "已克隆 {count} 个入站",
"clonedMixed": "已克隆 {ok} 个,失败 {failed} 个",
"inboundDeleteSuccess": "入站连接已成功删除",
"inboundClientAddSuccess": "已添加入站客户端",
"inboundClientDeleteSuccess": "入站客户端已删除",
+2
View File
@@ -412,6 +412,8 @@
"inboundCreateSuccess": "入站連接已成功建立",
"bulkDeleted": "已刪除 {count} 個入站",
"bulkDeletedMixed": "已刪除 {ok} 個,失敗 {failed} 個",
"clonedMany": "已複製 {count} 個入站",
"clonedMixed": "已複製 {ok} 個,失敗 {failed} 個",
"inboundDeleteSuccess": "入站連接已成功刪除",
"inboundClientAddSuccess": "已新增入站客戶端",
"inboundClientDeleteSuccess": "入站客戶端已刪除",