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