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