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
@@ -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>