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,
};