From 335470607f0d58923be85311c01d83a19d9fde6a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 14 Jun 2026 23:25:37 +0200 Subject: [PATCH] fix(ui): match node connection-outbound picker to panel-outbound selector Group the tags into Outbounds/Balancers, hide blackhole outbounds, and show the 'Direct connection' placeholder on empty via getValueProps so the field never looks unset and an empty default can't read as a second 'direct'. --- frontend/src/api/queries/useOutboundTags.ts | 35 +++++++++++++++++++++ frontend/src/pages/nodes/NodeFormModal.tsx | 23 ++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/queries/useOutboundTags.ts b/frontend/src/api/queries/useOutboundTags.ts index 33c242886..60e55a91f 100644 --- a/frontend/src/api/queries/useOutboundTags.ts +++ b/frontend/src/api/queries/useOutboundTags.ts @@ -34,3 +34,38 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) { }, }); } + +export interface OutboundTagGroups { + outbounds: string[]; + balancers: string[]; +} + +// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a +// picker can render them in labeled groups (like the panel-outbound selector) +// instead of one flat list. +export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) { + const excludeBlackhole = opts?.excludeBlackhole ?? false; + return useQuery({ + queryKey: keys.xray.config(), + queryFn: fetchXrayConfig, + staleTime: Infinity, + select: (data): OutboundTagGroups => { + const outbounds = new Set(); + for (const o of data?.xraySetting?.outbounds ?? []) { + const ob = o as { tag?: string; protocol?: string } | null; + if (!ob?.tag) continue; + if (excludeBlackhole && ob.protocol === 'blackhole') continue; + outbounds.add(ob.tag); + } + for (const t of data?.subscriptionOutboundTags ?? []) { + if (t) outbounds.add(t); + } + const balancers: string[] = []; + const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers; + for (const b of bal ?? []) { + if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag); + } + return { outbounds: [...outbounds], balancers }; + }, + }); +} diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 3941f70bf..f202cbcdf 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -18,7 +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 { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -77,7 +77,23 @@ 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 }); + 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]); useEffect(() => { if (!open) return; @@ -364,12 +380,13 @@ export default function NodeFormModal({ label={t('pages.nodes.outboundTag')} name="outboundTag" extra={t('pages.nodes.outboundTagHint')} + getValueProps={(v) => ({ value: (v as string) || undefined })} >