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
@@ -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>
</>
);
}