mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 14:21:01 +00:00
feat(node): per node outbound routing (#5275)
* feat: add per-node outbound routing for panel-to-node connections * feat(ui): add outbound tag selector to node form with i18n * fix(xray): avoid potential overflow warning in node egress rule allocation * chore: run "npm run gen" * fix --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
||||
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
||||
// the inbound form costs no extra request when the Xray page was already
|
||||
// visited; `select` derives just the tag list without disturbing other readers.
|
||||
export function useOutboundTags() {
|
||||
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
||||
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||
return useQuery({
|
||||
queryKey: keys.xray.config(),
|
||||
queryFn: fetchXrayConfig,
|
||||
@@ -15,8 +16,10 @@ export function useOutboundTags() {
|
||||
select: (data): string[] => {
|
||||
const tags = new Set<string>();
|
||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
||||
const tag = (o as { tag?: string } | null)?.tag;
|
||||
if (tag) tags.add(tag);
|
||||
const ob = o as { tag?: string; protocol?: string } | null;
|
||||
if (!ob?.tag) continue;
|
||||
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||
tags.add(ob.tag);
|
||||
}
|
||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
||||
if (t) tags.add(t);
|
||||
|
||||
@@ -354,6 +354,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"memPct": 45.1,
|
||||
"name": "de-fra-1",
|
||||
"onlineCount": 3,
|
||||
"outboundTag": "",
|
||||
"panelVersion": "v3.x.x",
|
||||
"parentGuid": "",
|
||||
"pinnedCertSha256": "",
|
||||
|
||||
@@ -1595,6 +1595,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 3,
|
||||
"type": "integer"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
"panelVersion": {
|
||||
"example": "v3.x.x",
|
||||
"type": "string"
|
||||
@@ -1682,6 +1685,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"memPct",
|
||||
"name",
|
||||
"onlineCount",
|
||||
"outboundTag",
|
||||
"panelVersion",
|
||||
"pinnedCertSha256",
|
||||
"port",
|
||||
|
||||
@@ -358,6 +358,7 @@ export interface Node {
|
||||
memPct: number;
|
||||
name: string;
|
||||
onlineCount: number;
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
parentGuid?: string;
|
||||
pinnedCertSha256: string;
|
||||
|
||||
@@ -384,6 +384,7 @@ export const NodeSchema = z.object({
|
||||
memPct: z.number(),
|
||||
name: z.string(),
|
||||
onlineCount: z.number().int(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
parentGuid: z.string().optional(),
|
||||
pinnedCertSha256: z.string(),
|
||||
|
||||
@@ -18,6 +18,7 @@ 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';
|
||||
import { useOutboundTags } from '@/api/queries/useOutboundTags';
|
||||
import './NodeFormModal.css';
|
||||
|
||||
type Mode = 'add' | 'edit';
|
||||
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
|
||||
pinnedCertSha256: '',
|
||||
inboundSyncMode: 'all',
|
||||
inboundTags: [],
|
||||
outboundTag: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ export default function NodeFormModal({
|
||||
const scheme = Form.useWatch('scheme', form) ?? 'https';
|
||||
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
|
||||
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
|
||||
const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -117,6 +120,7 @@ export default function NodeFormModal({
|
||||
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
|
||||
inboundSyncMode: values.inboundSyncMode,
|
||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||
outboundTag: values.outboundTag || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +360,19 @@ export default function NodeFormModal({
|
||||
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.nodes.outboundTag')}
|
||||
name="outboundTag"
|
||||
extra={t('pages.nodes.outboundTagHint')}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.nodes.outboundTagPlaceholder')}
|
||||
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.nodes.inboundSyncMode')}
|
||||
name="inboundSyncMode"
|
||||
|
||||
@@ -34,6 +34,7 @@ export const NodeRecordSchema = z.object({
|
||||
inboundSyncMode: z.enum(['all', 'selected']).optional(),
|
||||
// Backend serializes a nil []string as null for nodes saved before #5178.
|
||||
inboundTags: z.array(z.string()).nullish(),
|
||||
outboundTag: z.string().optional(),
|
||||
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||
guid: z.string().optional(),
|
||||
@@ -70,6 +71,7 @@ export const NodeFormSchema = z.object({
|
||||
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
|
||||
// serialized as null by the backend for a nil slice — tolerate both.
|
||||
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
|
||||
outboundTag: z.string().optional(),
|
||||
});
|
||||
|
||||
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
|
||||
|
||||
Reference in New Issue
Block a user