mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
feat(frontend): multi-node cloning initial implementation (#6216)
* feat(frontend): multinode cloning initial implementation * fix(frontend): harden live node detection in multinode cloning Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(frontend): add aria label to clone inbound modal * fix(frontend): shallow copy inbound settings during cloning * fix(frontend): avoid potential port conflict during testing * fix(frontend): selection buttons and websocket selection reset fix --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user