import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Col, Form, Input, InputNumber, Modal, Row, Select, Switch, message, } from 'antd'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import { HttpUtil, type Msg } from '@/utils'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import { currentPanelMasterUrl } from './agentMasterUrl'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; interface NodeFormModalProps { open: boolean; mode: Mode; node: NodeRecord | null; testConnection: (payload: Partial) => Promise>; fetchFingerprint: (payload: Partial) => Promise>; fetchInbounds: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } function defaultValues(): NodeFormValues { return { id: 0, name: '', remark: '', nodeType: 'traditional', scheme: 'https', address: '', port: 2053, basePath: '/', apiToken: '', hasStoredToken: false, enable: true, allowPrivateAddress: false, tlsVerifyMode: 'verify', pinnedCertSha256: '', inboundSyncMode: 'all', inboundTags: [], outboundTag: '', }; } export default function NodeFormModal({ open, mode, node, testConnection, fetchFingerprint, fetchInbounds, save, onOpenChange, }: NodeFormModalProps) { const { t } = useTranslation(); const methods = useForm({ defaultValues: defaultValues() }); const [messageApi, messageContextHolder] = message.useMessage(); const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); const [fetchingPin, setFetchingPin] = useState(false); const [fetchingInbounds, setFetchingInbounds] = useState(false); const [inboundOptions, setInboundOptions] = useState([]); const [testResult, setTestResult] = useState(null); const [agentToken, setAgentToken] = useState(null); const [agentNodeName, setAgentNodeName] = useState(''); const [agentMasterUrl, setAgentMasterUrl] = useState(''); // Edit mode (item 19): the stored install material for an existing agent // node, fetched from the master; null until loaded / for non-agent nodes. // Keyed by node id so a stale result from a previously edited node is // never shown (visibility is derived during render below). const [editInstall, setEditInstall] = useState<{ nodeId: number; name: string; token: string; hasToken: boolean; } | null>(null); const [editMasterUrl, setEditMasterUrl] = useState(''); const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https'; const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify'; const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all'; const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional'; const isAgent = nodeType === 'agent'; const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true }); // Outbounds and balancers share one picker (like the panel-outbound selector); // when balancers exist they get a labeled group so it's clear the selection // routes through a balancer. Empty falls back to the placeholder ("Direct // connection") rather than a synthetic option, so it can't read as a second // "direct" next to a real freedom outbound. const outboundOptions = useMemo< ( | { label: string; value: string } | { label: string; options: { label: string; value: string }[] } )[] >(() => { const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag })); if (!outboundGroups?.balancers.length) return outOpts; return [ { label: t('pages.xray.Outbounds'), options: outOpts }, { label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })), }, ]; }, [outboundGroups, t]); // Reset during render, not in an effect, so the first frame is already clean. const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null); if (!open) { if (synced) setSynced(null); } else if (!synced || synced.mode !== mode || synced.node !== (node ?? null)) { setSynced({ mode, node: node ?? null }); const base = defaultValues(); const next: NodeFormValues = mode === 'edit' && node ? { ...base, ...(node as unknown as Partial), id: node.id, scheme: (node.scheme as 'http' | 'https') || base.scheme, inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode, inboundTags: node.inboundTags ?? [], apiToken: '', hasStoredToken: node.hasApiToken ?? false, } : base; if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; methods.reset(next); setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setTestResult(null); } // Load the stored install material when editing an agent node so the // full install command can be re-displayed (item 19). Nodes created // before tokens were stored answer hasToken=false until the agent's // next authenticated request backfills the token. const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0; useEffect(() => { if (!open || !editNodeId) return; let cancelled = false; HttpUtil.get<{ name: string; token: string; hasToken: boolean }>( `/panel/api/agent/install/${editNodeId}`, ).then((msg) => { if (!cancelled && msg?.success && msg.obj) { setEditInstall({ nodeId: editNodeId, ...msg.obj }); setEditMasterUrl(currentPanelMasterUrl()); } }); return () => { cancelled = true; }; }, [open, editNodeId]); const shownInstall = editInstall && editInstall.nodeId === editNodeId ? editInstall : null; const editInstallCmd = useMemo(() => { if (!shownInstall || !editMasterUrl) return ''; const base = `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` + ` --master ${editMasterUrl}`; return shownInstall.hasToken ? `${base} --token ${shownInstall.token} --name ${shownInstall.name}` : `${base} --name ${shownInstall.name}`; }, [shownInstall, editMasterUrl]); const title = useMemo( () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), [mode, t], ); const editingWithToken = mode === 'edit' && Boolean(node?.hasApiToken); function buildPayload(values: NodeFormValues): Partial { const token = values.apiToken.trim(); const agentMode = values.nodeType === 'agent'; const payload: Partial = { id: values.id || 0, name: values.name.trim(), remark: values.remark?.trim() || '', nodeType: values.nodeType, scheme: values.scheme, // Agent nodes have no panel address; the backend ignores these. address: agentMode ? 'agent' : values.address.trim(), port: agentMode ? 2053 : values.port, basePath: values.basePath.trim() || '/', enable: values.enable, allowPrivateAddress: values.allowPrivateAddress, tlsVerifyMode: values.tlsVerifyMode, pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', inboundSyncMode: values.inboundSyncMode, inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], outboundTag: values.outboundTag || '', }; if (token) payload.apiToken = token; return payload; } async function onTest() { if (!(await methods.trigger(['name', 'address', 'port']))) return; setTesting(true); setTestResult(null); try { const payload = buildPayload(methods.getValues()); const msg = await testConnection(payload); if (msg?.success && msg.obj) { setTestResult(msg.obj); } else { setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' }); } } finally { setTesting(false); } } async function onFetchPin() { if (!(await methods.trigger(['name', 'address', 'port']))) return; setFetchingPin(true); try { const payload = buildPayload(methods.getValues()); const msg = await fetchFingerprint(payload); if (msg?.success && msg.obj) { methods.setValue('pinnedCertSha256', msg.obj); messageApi.success(t('pages.nodes.pinFetched')); } else { messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed')); } } finally { setFetchingPin(false); } } async function onFetchInbounds() { if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return; setFetchingInbounds(true); try { const msg = await fetchInbounds(buildPayload(methods.getValues())); if (msg?.success && Array.isArray(msg.obj)) { setInboundOptions(msg.obj); messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length })); } else { messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed')); } } finally { setFetchingInbounds(false); } } async function onFinish(values: NodeFormValues) { const result = NodeFormSchema.safeParse(values); if (!result.success) { messageApi.error(t(result.error.issues[0]?.message ?? 'pages.nodes.toasts.fillRequired')); return; } setSubmitting(true); try { const payload = buildPayload(result.data); const agentMode = result.data.nodeType === 'agent'; // Agent nodes have no panel to probe; skip the connection test. if (!agentMode) { const test = await testConnection(payload); const probe = test?.success ? test.obj : null; if (!probe || probe.status !== 'online') { setTestResult( probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') }, ); return; } setTestResult(probe); } const msg = await save(payload); if (msg?.success) { // Backend returns { node, agentToken, nodeType } for agent nodes. const obj = msg.obj as unknown as | { agentToken?: string; node?: { id?: number } } | undefined; if (agentMode && mode === 'add' && obj?.agentToken) { setAgentToken(obj.agentToken); setAgentNodeName(result.data.name.trim()); // Default the master URL to the address this panel is being // viewed through — origin AND base path. Dropping the base // path made the agent's registration 404. User can edit it. setAgentMasterUrl(currentPanelMasterUrl()); // Keep the modal open state; the token dialog shows on top. } else { onOpenChange(false); } } } finally { setSubmitting(false); } } function close() { if (!submitting) onOpenChange(false); } // The install command is only meaningful with a master URL; an empty // one would produce a command that can never register, so withhold it // (and say why) instead of letting the user copy a dead command. const installCmd = agentMasterUrl ? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}` : ''; return ( <> {messageContextHolder}
{!isAgent && ( { if (value === 'http') methods.setValue('tlsVerifyMode', 'skip'); }} > )} {!isAgent && ( )} {isAgent && ( )} {mode === 'edit' && isAgent && shownInstall && (
{t('pages.nodes.agentInstallCommand')}

{t('pages.nodes.agentMasterUrlHint')}

setEditMasterUrl(e.target.value.trim())} style={{ marginBottom: 12, fontFamily: 'monospace' }} /> {!shownInstall.hasToken && ( )}
)} {!isAgent && ( )} {!isAgent && ( ( <> {menu} )} options={inboundOptions.map((inbound) => ({ value: inbound.tag, label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`, }))} /> )} {!isAgent && (
{testResult && (
{testResult.status === 'online' ? ( ) : ( )}
)}
)}
{/* Agent install command - shown once after creating an agent node */} { navigator.clipboard.writeText(installCmd).then( () => messageApi.success(t('pages.nodes.agentTokenCopied')), () => messageApi.error(t('pages.nodes.agentTokenCopyFailed')), ); }} > {t('pages.nodes.copyToken')} , , ]} onCancel={() => { setAgentToken(null); onOpenChange(false); }} >

{t('pages.nodes.agentMasterUrlHint')}

setAgentMasterUrl(e.target.value.trim())} style={{ marginBottom: 12, fontFamily: 'monospace' }} /> {!agentMasterUrl && ( )}

{t('pages.nodes.agentTokenHint')}

{t('pages.nodes.agentTokenInstructions')}

); }