diff --git a/.gitignore b/.gitignore index 6ea141728..7af8aae5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore editor and IDE settings .idea/ .vscode/ +.cursor/ .claude/ .cache/ .sync* diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index b11dc4ff3..d2fb7afd6 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form'; import { setMessageInstance } from '@/utils/messageBus'; import { BasicsTab } from './basics'; +import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; @@ -118,11 +119,8 @@ export default function XrayPage() { mutate((tt) => { if (!tt.outbounds || payload.index < 0) return; tt.outbounds[payload.index] = payload.outbound as never; - if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) { - const rules = tt.routing?.rules || []; - for (const r of rules) { - if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag; - } + if (payload.oldTag && payload.newTag) { + propagateOutboundTagRename(tt, payload.oldTag, payload.newTag); } }); } diff --git a/frontend/src/pages/xray/basics/helpers.ts b/frontend/src/pages/xray/basics/helpers.ts index eb400da1f..48df3e0c9 100644 --- a/frontend/src/pages/xray/basics/helpers.ts +++ b/frontend/src/pages/xray/basics/helpers.ts @@ -54,3 +54,36 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record if (!haveRules && idx > 0) t.outbounds.splice(idx, 1); if (haveRules && idx < 0) t.outbounds.push(settings as never); } + +export function propagateOutboundTagRename( + t: XraySettingsValue, + oldTag: string, + newTag: string, +): void { + if (!oldTag || !newTag || oldTag === newTag) return; + + const rules = t.routing?.rules; + if (Array.isArray(rules)) { + for (const rule of rules) { + if (rule?.outboundTag === oldTag) rule.outboundTag = newTag; + } + } + + const balancers = t.routing?.balancers; + if (Array.isArray(balancers)) { + for (const balancer of balancers) { + if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag; + if (Array.isArray(balancer?.selector)) { + balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel)); + } + } + } + + if (Array.isArray(t.outbounds)) { + for (const outbound of t.outbounds) { + const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) + ?.streamSettings?.sockopt; + if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag; + } + } +} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index be8f4c9c6..ae932c37f 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -38,6 +38,7 @@ import { import { HttpUtil } from '@/utils'; import OutboundFormModal from './OutboundFormModal'; +import { propagateOutboundTagRename } from '../basics/helpers'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; @@ -172,11 +173,16 @@ export default function OutboundsTab({ function onConfirm(outbound: Record) { mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; + const newTag = typeof outbound.tag === 'string' ? outbound.tag : ''; if (editingIndex == null) { - if (!outbound.tag) return; + if (!newTag) return; tt.outbounds.push(outbound as never); } else { + const oldTag = tt.outbounds[editingIndex]?.tag; tt.outbounds[editingIndex] = outbound as never; + if (oldTag && newTag && oldTag !== newTag) { + propagateOutboundTagRename(tt, oldTag, newTag); + } } }); setModalOpen(false); diff --git a/frontend/src/test/outbound-tag-rename.test.ts b/frontend/src/test/outbound-tag-rename.test.ts new file mode 100644 index 000000000..fbf7ff6c3 --- /dev/null +++ b/frontend/src/test/outbound-tag-rename.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; + +import type { XraySettingsValue } from '@/hooks/useXraySetting'; +import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers'; + +function baseTemplate(): XraySettingsValue { + return { + outbounds: [ + { tag: 'To-External-Proxy', protocol: 'vless' }, + { tag: 'direct', protocol: 'freedom' }, + ], + routing: { + rules: [ + { + type: 'field', + inboundTag: ['iran-in'], + outboundTag: 'To-External-Proxy', + }, + ], + balancers: [ + { + tag: 'lb-1', + selector: ['To-External-Proxy', 'direct'], + fallbackTag: 'To-External-Proxy', + }, + ], + }, + } as XraySettingsValue; +} + +describe('propagateOutboundTagRename', () => { + it('updates routing rule outboundTag when outbound is renamed', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps'); + }); + + it('updates balancer selector and fallbackTag', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']); + expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps'); + }); + + it('updates sockopt dialerProxy references in other outbounds', () => { + const t = baseTemplate(); + (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = { + sockopt: { dialerProxy: 'To-External-Proxy' }, + }; + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) + .streamSettings?.sockopt?.dialerProxy; + expect(dialerProxy).toBe('external-vps'); + }); + + it('is a no-op when old and new tags are equal', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy'); + expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy'); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index c2f6e0a64..62f1a347f 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -74,3 +74,19 @@ afterEach(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); } }); + +import { HttpUtil } from '@/utils'; + +vi.mock('axios', () => { + return { + default: { + get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), + post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), + } + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);