diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d142f0d43..d87b5265e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1572,6 +1572,19 @@ "example": 5, "type": "integer" }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, "lastError": { "type": "string" }, @@ -1675,6 +1688,8 @@ "guid", "id", "inboundCount", + "inboundSyncMode", + "inboundTags", "lastError", "lastHeartbeat", "latencyMs", @@ -6011,6 +6026,10 @@ "guid": "", "id": 1, "inboundCount": 5, + "inboundSyncMode": "all", + "inboundTags": [ + "" + ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, @@ -6451,6 +6470,65 @@ } } }, + "/panel/api/nodes/inbounds": { + "post": { + "tags": [ + "Nodes" + ], + "summary": "Use unsaved node connection details to list the remote inbounds available for selective import.", + "operationId": "post_panel_api_nodes_inbounds", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "de-fra-1", + "scheme": "https", + "address": "node1.example.com", + "port": 2053, + "basePath": "/", + "apiToken": "abcdef..." + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + { + "tag": "inbound-443", + "remark": "VLESS", + "protocol": "vless", + "port": 443 + } + ] + } + } + } + } + } + } + }, "/panel/api/nodes/probe/{id}": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index abdc0c249..aad3b12ca 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -15,6 +15,13 @@ export interface NodeUpdateResult { error?: string; } +export interface RemoteInboundOption { + tag: string; + remark?: string; + protocol?: string; + port?: number; +} + export function useNodeMutations() { const queryClient = useQueryClient(); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); @@ -72,5 +79,7 @@ export function useNodeMutations() { }, fetchFingerprint: (payload: Partial): Promise> => HttpUtil.post('/panel/api/nodes/certFingerprint', payload), + fetchInbounds: (payload: Partial): Promise> => + HttpUtil.post('/panel/api/nodes/inbounds', payload), }; } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 16992415f..3f4ea7e32 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -342,6 +342,10 @@ export const EXAMPLES: Record = { "guid": "", "id": 1, "inboundCount": 5, + "inboundSyncMode": "all", + "inboundTags": [ + "" + ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 6c63731fb..4a8b6d186 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1546,6 +1546,19 @@ export const SCHEMAS: Record = { "example": 5, "type": "integer" }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, "lastError": { "type": "string" }, @@ -1649,6 +1662,8 @@ export const SCHEMAS: Record = { "guid", "id", "inboundCount", + "inboundSyncMode", + "inboundTags", "lastError", "lastHeartbeat", "latencyMs", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c9869acf0..725b99120 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -348,6 +348,8 @@ export interface Node { guid: string; id: number; inboundCount: number; + inboundSyncMode: string; + inboundTags: string[]; lastError: string; lastHeartbeat: number; latencyMs: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index b3207d6c1..3db667349 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -374,6 +374,8 @@ export const NodeSchema = z.object({ guid: z.string(), id: z.number().int(), inboundCount: z.number().int(), + inboundSyncMode: z.enum(['all', 'selected']), + inboundTags: z.array(z.string()), lastError: z.string(), lastHeartbeat: z.number().int(), latencyMs: z.number().int(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f948bcb71..e9aa879e1 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -843,6 +843,13 @@ export const sections: readonly Section[] = [ body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}', response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}', }, + { + method: 'POST', + path: '/panel/api/nodes/inbounds', + summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.', + body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', + response: '{\n "success": true,\n "obj": [\n { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n ]\n}', + }, { method: 'POST', path: '/panel/api/nodes/probe/:id', diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 985e80d3a..a6906a52e 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -14,6 +14,7 @@ import { message, } from 'antd'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { Msg } from '@/utils'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { antdRule } from '@/utils/zodForm'; @@ -27,6 +28,7 @@ interface NodeFormModalProps { node: NodeRecord | null; testConnection: (payload: Partial) => Promise>; fetchFingerprint: (payload: Partial) => Promise>; + fetchInbounds: (payload: Partial) => Promise>; save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } @@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues { allowPrivateAddress: false, tlsVerifyMode: 'verify', pinnedCertSha256: '', + inboundSyncMode: 'all', + inboundTags: [], }; } @@ -54,6 +58,7 @@ export default function NodeFormModal({ node, testConnection, fetchFingerprint, + fetchInbounds, save, onOpenChange, }: NodeFormModalProps) { @@ -64,9 +69,12 @@ export default function NodeFormModal({ 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 scheme = Form.useWatch('scheme', form) ?? 'https'; const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; + const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all'; useEffect(() => { if (!open) return; @@ -82,6 +90,7 @@ export default function NodeFormModal({ if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; form.resetFields(); form.setFieldsValue(next); + setInboundOptions((next.inboundTags || []).map((tag) => ({ tag }))); setTestResult(null); }, [open, mode, node, form]); @@ -104,6 +113,8 @@ export default function NodeFormModal({ allowPrivateAddress: values.allowPrivateAddress, tlsVerifyMode: values.tlsVerifyMode, pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', + inboundSyncMode: values.inboundSyncMode, + inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], }; } @@ -149,6 +160,26 @@ export default function NodeFormModal({ } } + async function onFetchInbounds() { + try { + await form.validateFields(['name', 'address', 'port', 'apiToken']); + } catch { + return; + } + setFetchingInbounds(true); + try { + const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true))); + 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) { @@ -323,6 +354,46 @@ export default function NodeFormModal({ + + ( + <> + + {menu} + + )} + options={inboundOptions.map((inbound) => ({ + value: inbound.tag, + label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`, + }))} + /> + + )} +