diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 50770a086..d630ccf0d 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -30,6 +30,7 @@ import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; +import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback'; import { DnsTab } from './dns'; import { WarpModal, NordModal } from './overrides'; import './XrayPage.css'; @@ -190,6 +191,20 @@ export default function XrayPage() { navigate('/xray#advanced'); return; } + if (templateSettings) { + const clone = JSON.parse(JSON.stringify(templateSettings)); + ensureMissingBalancerLoopbacks(clone); + cleanupOrphanedBalancerLoopbacks(clone); + const cycles = detectBalancerCycles(clone); + if (cycles.length > 0) { + const names = cycles.map((c) => c.join(' → ')).join(', '); + messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')'); + return; + } + const serialized = JSON.stringify(clone, null, 2); + setXraySetting(serialized); + setTemplateSettings(clone); + } saveAll(); } diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx index 68712bbf5..1e31f7570 100644 --- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; +import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import type { Path } from 'react-hook-form'; import { InputAddon } from '@/components/ui'; import { FormField } from '@/components/form/rhf'; +import type { XraySettingsValue } from '@/hooks/useXraySetting'; import { BalancerFormSchema, type BalancerFormValues, @@ -14,7 +16,9 @@ import { import { BalancerStrategyTypeSchema, type BalancerStrategyType, + type BalancerObject, } from '@/schemas/routing'; +import { isBalancerLoopbackTag } from './balancer-loopback'; export type BalancerFormValue = BalancerFormValues; @@ -22,6 +26,9 @@ interface BalancerFormModalProps { open: boolean; balancer: BalancerFormValue | null; outboundTags: string[]; + balancerTags: string[]; + balancers: BalancerObject[]; + templateSettings: XraySettingsValue | null; otherTags: string[]; onClose: () => void; onConfirm: (value: BalancerFormValue) => void; @@ -56,6 +63,9 @@ export default function BalancerFormModal({ open, balancer, outboundTags, + balancerTags, + balancers, + templateSettings, otherTags, onClose, onConfirm, @@ -75,6 +85,83 @@ export default function BalancerFormModal({ const strategy = useWatch({ control: methods.control, name: 'strategy' }); const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? []; const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? []; + const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? ''; + const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? ''; + const currentTag = tagValue.trim(); + + const availableBalancerTags = useMemo( + () => balancerTags.filter((tg) => tg !== currentTag), + [balancerTags, currentTag], + ); + + const cycleInfo = useMemo(() => { + const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; + const resolveLoopback = (tag: string): string | null => { + for (const r of rules) { + if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) { + return r.balancerTag; + } + } + return null; + }; + + const fallbackOf: Record = {}; + for (const b of balancers) { + if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue; + const target = isBalancerLoopbackTag(b.fallbackTag) + ? resolveLoopback(b.fallbackTag) + : b.fallbackTag; + if (target) fallbackOf[b.tag] = target; + } + + const result: Record = {}; + for (const tg of availableBalancerTags) { + const visited = new Set(); + let cursor = tg; + const path = [tg]; + while (cursor && !visited.has(cursor)) { + if (cursor === currentTag) { + result[tg] = path; + break; + } + visited.add(cursor); + cursor = fallbackOf[cursor] || ''; + if (cursor) path.push(cursor); + } + } + return result; + }, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]); + + const wouldCreateCycle = !!cycleInfo[fallbackTag]; + + const fallbackOptions = useMemo(() => { + const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = [ + { value: '', label: `(${t('none')})` }, + ]; + for (const tg of outboundTags) { + options.push({ value: tg, label: tg }); + } + for (const tg of availableBalancerTags) { + const cycle = cycleInfo[tg]; + options.push({ + value: tg, + disabled: !!cycle, + title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined, + label: ( + + {t('pages.xray.rules.balancer')} + {tg} + + ), + }); + } + return options; + }, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]); + + const isFallbackBalancer = useMemo( + () => balancerTags.includes(fallbackTag), + [balancerTags, fallbackTag], + ); function submit() { const values = methods.getValues(); @@ -92,7 +179,7 @@ export default function BalancerFormModal({ } } } - if (!parsed.success || duplicateTag) { + if (!parsed.success || duplicateTag || wouldCreateCycle) { setSubmitAttempted(true); return; } @@ -101,11 +188,6 @@ export default function BalancerFormModal({ onConfirm(result); } - const fallbackOptions = useMemo( - () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })), - [outboundTags, t], - ); - const title = isEdit ? `${t('edit')} ${t('pages.xray.Balancers')}` : `+ ${t('pages.xray.Balancers')}`; @@ -117,6 +199,7 @@ export default function BalancerFormModal({ title={title} okText={okText} cancelText={t('close')} + okButtonProps={{ disabled: wouldCreateCycle }} mask={{ closable: false }} onOk={submit} onCancel={onClose} @@ -165,10 +248,27 @@ export default function BalancerFormModal({ v ?? '' }} > ({ label: tag, value: tag }))} + value={resolvedOverride} + options={options} onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')} /> ); @@ -353,6 +444,7 @@ export default function BalancersTab({ return ( <> {modalContextHolder} + {messageContextHolder} setModalOpen(false)} onConfirm={onConfirm} diff --git a/frontend/src/pages/xray/balancers/balancer-loopback.ts b/frontend/src/pages/xray/balancers/balancer-loopback.ts new file mode 100644 index 000000000..db90cf7de --- /dev/null +++ b/frontend/src/pages/xray/balancers/balancer-loopback.ts @@ -0,0 +1,198 @@ +import type { XraySettingsValue } from '@/hooks/useXraySetting'; + +const LOOPBACK_PREFIX = '_bl_'; + +export function isBalancerLoopbackTag(tag: string): boolean { + return tag.startsWith(LOOPBACK_PREFIX); +} + +export function loopbackTagFor(targetBalancerTag: string): string { + return LOOPBACK_PREFIX + targetBalancerTag; +} + +export function balancerTagFromLoopback(loopbackTag: string): string | null { + if (!isBalancerLoopbackTag(loopbackTag)) return null; + return loopbackTag.slice(LOOPBACK_PREFIX.length); +} + +function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean { + if (!isBalancerLoopbackTag(loopbackTag)) return false; + const target = balancerTagFromLoopback(loopbackTag); + return target === targetTag; +} + +function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null { + const rules = (settings.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; + for (const r of rules) { + if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) { + return r.balancerTag; + } + } + return null; +} + +export function resolveLoopbackFallback( + settings: XraySettingsValue, + fallbackTag: string, +): string { + if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag; + const target = findLoopbackTarget(settings, fallbackTag); + if (target) return target; + const targetTag = balancerTagFromLoopback(fallbackTag); + return targetTag || fallbackTag; +} + +function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number { + let count = 0; + for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) { + if (b.fallbackTag && isBalancerLoopbackTag(b.fallbackTag) && loopbackMatchesTarget(b.fallbackTag, targetTag)) { + count++; + } + } + return count; +} + +export function ensureBalancerLoopback( + settings: XraySettingsValue, + targetBalancerTag: string, +): void { + const lbTag = loopbackTagFor(targetBalancerTag); + + if (!Array.isArray(settings.outbounds)) settings.outbounds = []; + const existingIdx = (settings.outbounds as Array<{ tag?: string; protocol?: string }>).findIndex( + (o) => o.tag === lbTag, + ); + const newOutbound = { tag: lbTag, protocol: 'loopback', settings: { inboundTag: lbTag } }; + if (existingIdx >= 0) { + (settings.outbounds as Record[])[existingIdx] = newOutbound; + } else { + (settings.outbounds as Record[]).push(newOutbound); + } + + if (!settings.routing) settings.routing = { rules: [], balancers: [] }; + if (!Array.isArray(settings.routing.rules)) settings.routing.rules = []; + + const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex( + (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag), + ); + if (existingRuleIdx >= 0) { + (settings.routing.rules as Record[])[existingRuleIdx].balancerTag = targetBalancerTag; + } else { + (settings.routing.rules as Record[]).push({ + type: 'field', + inboundTag: [lbTag], + balancerTag: targetBalancerTag, + }); + } +} + +export function removeBalancerLoopbackIfOrphaned( + settings: XraySettingsValue, + targetBalancerTag: string, +): void { + if (countLoopbackRefs(settings, targetBalancerTag) > 0) return; + removeBalancerLoopback(settings, targetBalancerTag); +} + +export function removeBalancerLoopback( + settings: XraySettingsValue, + targetBalancerTag: string, +): void { + const lbTag = loopbackTagFor(targetBalancerTag); + + if (Array.isArray(settings.outbounds)) { + settings.outbounds = (settings.outbounds as Array<{ tag?: string }>).filter( + (o) => o.tag !== lbTag, + ) as XraySettingsValue['outbounds']; + } + + if (settings.routing && Array.isArray(settings.routing.rules)) { + settings.routing.rules = settings.routing.rules.filter( + (r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)), + ); + } +} + +export function propagateBalancerTagRename( + settings: XraySettingsValue, + oldTag: string, + newTag: string, +): void { + const oldLbTag = loopbackTagFor(oldTag); + const newLbTag = loopbackTagFor(newTag); + + if (Array.isArray(settings.outbounds)) { + for (const o of settings.outbounds as Array<{ tag?: string; settings?: { inboundTag?: string } }>) { + if (o.tag === oldLbTag) o.tag = newLbTag; + if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag; + } + } + + if (settings.routing && Array.isArray(settings.routing.rules)) { + for (const r of settings.routing.rules as Array<{ inboundTag?: string[] }>) { + if (Array.isArray(r.inboundTag)) { + const idx = r.inboundTag.indexOf(oldLbTag); + if (idx !== -1) r.inboundTag[idx] = newLbTag; + } + } + } + + if (settings.routing && Array.isArray(settings.routing.balancers)) { + for (const b of settings.routing.balancers as Array<{ tag?: string; fallbackTag?: string }>) { + if (b.fallbackTag === oldLbTag) b.fallbackTag = newLbTag; + } + } +} + +export function detectBalancerCycles(settings: XraySettingsValue): string[][] { + const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>; + const cycles: string[][] = []; + + for (const b of balancers) { + if (!b.tag || !b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue; + const targetTag = balancerTagFromLoopback(b.fallbackTag); + if (!targetTag) continue; + + const visited = new Set(); + let cursor = targetTag; + while (cursor && !visited.has(cursor)) { + if (cursor === b.tag) { + cycles.push([b.tag, targetTag]); + break; + } + visited.add(cursor); + const next = balancers.find((x) => x.tag === cursor); + const fb = next?.fallbackTag; + if (!fb || !isBalancerLoopbackTag(fb)) break; + cursor = balancerTagFromLoopback(fb) || ''; + } + } + return cycles; +} + +export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void { + const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>; + for (const b of balancers) { + if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue; + const targetTag = balancerTagFromLoopback(b.fallbackTag); + if (!targetTag) continue; + ensureBalancerLoopback(settings, targetTag); + } +} + +export function cleanupOrphanedBalancerLoopbacks(settings: XraySettingsValue): void { + if (!Array.isArray(settings.outbounds)) return; + + const orphanedTags: string[] = []; + for (const o of settings.outbounds as Array<{ tag?: string; protocol?: string }>) { + if (o.protocol !== 'loopback' || !o.tag || !isBalancerLoopbackTag(o.tag)) continue; + const targetTag = balancerTagFromLoopback(o.tag); + if (targetTag && countLoopbackRefs(settings, targetTag) === 0) { + orphanedTags.push(targetTag); + } + } + + for (const tag of orphanedTags) { + removeBalancerLoopback(settings, tag); + } +} diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index a4417fdf9..8b9d68dd5 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -332,6 +332,10 @@ export default function OutboundFormModal({ messageApi.error(t('pages.xray.outboundForm.tagRequired')); return; } + if (tagValue.startsWith('_bl_')) { + messageApi.error(t('pages.xray.balancer.reservedPrefix')); + return; + } const isDuplicateTag = (existingTags || []).includes(tagValue) && !(isEdit && (outboundProp?.tag as string | undefined) === tagValue); if (isDuplicateTag) { diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 817a9931d..03adb5173 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -45,6 +45,7 @@ import OutboundFormModal from './OutboundFormModal'; import { propagateOutboundTagRename } from '../basics/helpers'; import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup'; import DeletionImpactList from '../DeletionImpactList'; +import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; @@ -142,7 +143,13 @@ export default function OutboundsTab({ [templateSettings?.outbounds], ); - const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]); + const rows = useMemo( + () => + outbounds + .map((o, i) => ({ ...o, key: i })) + .filter((o) => !isBalancerLoopbackTag(o.tag || '')), + [outbounds], + ); const dialerProxyTags = useMemo(() => { const tags = new Set(); diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 59e48ccb0..55b7135d1 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -14,6 +14,7 @@ import { import { catTabLabel } from '@/pages/settings/catTabLabel'; import PromptModal from '@/components/feedback/PromptModal'; import TextModal from '@/components/feedback/TextModal'; +import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; import RoutingBasic from './RoutingBasic'; import RouteTester from './RouteTester'; import RuleFormModal from './RuleFormModal'; @@ -66,26 +67,31 @@ export default function RoutingTab({ const rows: RuleRow[] = useMemo( () => - rules.map((rule, idx) => { - const r: RuleRow = { key: idx }; - r.enabled = rule.enabled !== false; - r.domain = arrJoin(rule.domain); - r.ip = arrJoin(rule.ip); - r.port = rule.port; - r.sourcePort = rule.sourcePort; - r.vlessRoute = rule.vlessRoute; - r.network = rule.network; - r.sourceIP = arrJoin(rule.sourceIP); - r.user = arrJoin(rule.user); - r.inboundTag = arrJoin(rule.inboundTag); - r.protocol = arrJoin(rule.protocol); - if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) { - r.attrs = JSON.stringify(rule.attrs, null, 2); - } - r.outboundTag = rule.outboundTag; - r.balancerTag = rule.balancerTag; - return r; - }), + rules + .map((rule, idx) => { + const r: RuleRow = { key: idx }; + r.enabled = rule.enabled !== false; + r.domain = arrJoin(rule.domain); + r.ip = arrJoin(rule.ip); + r.port = rule.port; + r.sourcePort = rule.sourcePort; + r.vlessRoute = rule.vlessRoute; + r.network = rule.network; + r.sourceIP = arrJoin(rule.sourceIP); + r.user = arrJoin(rule.user); + r.inboundTag = arrJoin(rule.inboundTag); + r.protocol = arrJoin(rule.protocol); + if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) { + r.attrs = JSON.stringify(rule.attrs, null, 2); + } + r.outboundTag = rule.outboundTag; + r.balancerTag = rule.balancerTag; + return r; + }) + .filter((r) => { + const inboundTags = (rules[r.key]?.inboundTag || []) as string[]; + return !inboundTags.some(isBalancerLoopbackTag); + }), [rules], ); @@ -105,7 +111,7 @@ export default function RoutingTab({ const seen = new Set(); const out: string[] = []; const push = (tag?: string) => { - if (!tag || seen.has(tag)) return; + if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return; seen.add(tag); out.push(tag); }; diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 7cd1f29a4..8c1e36e57 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -101,7 +101,10 @@ export const RuleFormSchema = z.object({ }); export const BalancerFormSchema = z.object({ - tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'), + tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired').refine( + (val) => !val.startsWith('_bl_'), + { message: 'pages.xray.balancer.reservedPrefix' }, + ), strategy: BalancerStrategyTypeSchema.default('random'), selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'), fallbackTag: z.string().default(''), @@ -111,7 +114,11 @@ export const BalancerFormSchema = z.object({ export const OutboundTagSchema = z .string() .trim() - .min(1, 'pages.xray.outboundTagRequired'); + .min(1, 'pages.xray.outboundTagRequired') + .refine( + (val) => !val.startsWith('_bl_'), + { message: 'pages.xray.balancer.reservedPrefix' }, + ); export type BalancerFormValues = z.infer; export type RuleFormValues = z.infer; diff --git a/frontend/src/test/balancer-form-modal.test.tsx b/frontend/src/test/balancer-form-modal.test.tsx index 2b077eca9..3a1c07d46 100644 --- a/frontend/src/test/balancer-form-modal.test.tsx +++ b/frontend/src/test/balancer-form-modal.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent } from '@testing-library/react'; import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal'; +import type { BalancerFormValue } from '@/pages/xray/balancers/BalancerFormModal'; +import type { BalancerObject } from '@/schemas/routing'; import { renderWithProviders } from './test-utils'; function renderModal(onConfirm = vi.fn()) { @@ -10,6 +12,9 @@ function renderModal(onConfirm = vi.fn()) { open balancer={null} outboundTags={['proxy', 'direct']} + balancerTags={[]} + balancers={[]} + templateSettings={null} otherTags={['existing']} onClose={() => {}} onConfirm={onConfirm} @@ -28,9 +33,9 @@ function explainText(): string { .join(' | '); } -function createButton(): HTMLElement { +function primaryButton(): HTMLElement { const btn = document.querySelector('.ant-modal-footer .ant-btn-primary'); - if (!btn) throw new Error('Create button not found'); + if (!btn) throw new Error('Primary button not found'); return btn as HTMLElement; } @@ -41,18 +46,43 @@ describe('BalancerFormModal', () => { expect(erroredItemCount()).toBe(0); expect(explainText()).not.toContain('Tag is required'); expect(explainText()).not.toContain('Pick at least one outbound'); - expect(createButton().hasAttribute('disabled')).toBe(false); + expect(primaryButton().hasAttribute('disabled')).toBe(false); }); it('reveals required-field errors only after a save attempt, without confirming', () => { const { onConfirm } = renderModal(); expect(erroredItemCount()).toBe(0); - fireEvent.click(createButton()); + fireEvent.click(primaryButton()); expect(erroredItemCount()).toBe(2); expect(explainText()).toContain('Tag is required'); expect(explainText()).toContain('Pick at least one outbound'); expect(onConfirm).not.toHaveBeenCalled(); }); + + it('disables save and warns when the chosen fallback would create a balancer cycle', () => { + const editing: BalancerFormValue = { tag: 'A', strategy: 'random', selector: ['proxy'], fallbackTag: 'B' }; + const others: BalancerObject[] = [{ tag: 'B', selector: ['direct'], fallbackTag: 'A' }]; + const onConfirm = vi.fn(); + renderWithProviders( + {}} + onConfirm={onConfirm} + />, + ); + + expect(document.querySelector('.ant-alert-error')).toBeTruthy(); + expect(primaryButton().hasAttribute('disabled')).toBe(true); + + fireEvent.click(primaryButton()); + expect(onConfirm).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/test/balancer-loopback.test.ts b/frontend/src/test/balancer-loopback.test.ts new file mode 100644 index 000000000..be142ae5c --- /dev/null +++ b/frontend/src/test/balancer-loopback.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect } from 'vitest'; + +import type { XraySettingsValue } from '@/hooks/useXraySetting'; +import { + isBalancerLoopbackTag, + loopbackTagFor, + balancerTagFromLoopback, + resolveLoopbackFallback, + ensureBalancerLoopback, + ensureMissingBalancerLoopbacks, + removeBalancerLoopback, + removeBalancerLoopbackIfOrphaned, + propagateBalancerTagRename, + detectBalancerCycles, + cleanupOrphanedBalancerLoopbacks, +} from '@/pages/xray/balancers/balancer-loopback'; + +interface OutboundEntry { + tag?: string; + protocol?: string; + settings?: { inboundTag?: string }; +} +interface RuleEntry { + type?: string; + inboundTag?: string[]; + balancerTag?: string; +} +interface BalancerEntry { + tag?: string; + selector?: string[]; + fallbackTag?: string; +} + +function makeSettings(input: { + outbounds?: OutboundEntry[]; + rules?: RuleEntry[]; + balancers?: BalancerEntry[]; +}): XraySettingsValue { + return { + outbounds: input.outbounds, + routing: { + rules: input.rules, + balancers: input.balancers, + }, + } as XraySettingsValue; +} + +function outboundTags(settings: XraySettingsValue): string[] { + return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? ''); +} + +function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] { + return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback'); +} + +function ruleEntries(settings: XraySettingsValue): RuleEntry[] { + return (settings.routing?.rules ?? []) as RuleEntry[]; +} + +function balancerEntries(settings: XraySettingsValue): BalancerEntry[] { + return (settings.routing?.balancers ?? []) as BalancerEntry[]; +} + +describe('loopback tag helpers', () => { + const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [ + { tag: '_bl_main', isLoopback: true, roundtrip: 'main' }, + { tag: 'main', isLoopback: false, roundtrip: null }, + { tag: '_bl_', isLoopback: true, roundtrip: '' }, + { tag: 'proxy_bl_', isLoopback: false, roundtrip: null }, + ]; + + it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => { + expect(isBalancerLoopbackTag(tag)).toBe(isLoopback); + expect(balancerTagFromLoopback(tag)).toBe(roundtrip); + }); + + it('builds a loopback tag that round-trips back to the balancer tag', () => { + expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a'); + expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a'); + }); +}); + +describe('resolveLoopbackFallback', () => { + const settings = makeSettings({ + rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }], + }); + + const cases: Array<{ name: string; input: string; expected: string }> = [ + { name: 'resolves a loopback tag through its routing rule', input: '_bl_bal1', expected: 'bal1' }, + { name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' }, + { name: 'returns an empty tag unchanged', input: '', expected: '' }, + { name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' }, + ]; + + it.each(cases)('$name', ({ input, expected }) => { + expect(resolveLoopbackFallback(settings, input)).toBe(expected); + }); +}); + +describe('ensureBalancerLoopback dedup', () => { + it('creates exactly one loopback outbound and one rule when called repeatedly', () => { + const settings = makeSettings({ outbounds: [], rules: [], balancers: [] }); + + ensureBalancerLoopback(settings, 'bal1'); + ensureBalancerLoopback(settings, 'bal1'); + + const loopbacks = loopbackOutbounds(settings); + expect(loopbacks).toHaveLength(1); + expect(loopbacks[0]).toEqual({ + tag: '_bl_bal1', + protocol: 'loopback', + settings: { inboundTag: '_bl_bal1' }, + }); + + const matchingRules = ruleEntries(settings).filter( + (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'), + ); + expect(matchingRules).toHaveLength(1); + expect(matchingRules[0].balancerTag).toBe('bal1'); + }); + + it('does not duplicate a loopback shared by multiple balancers', () => { + const settings = makeSettings({ + outbounds: [], + rules: [], + balancers: [ + { tag: 'A', selector: [], fallbackTag: '_bl_shared' }, + { tag: 'B', selector: [], fallbackTag: '_bl_shared' }, + ], + }); + + ensureMissingBalancerLoopbacks(settings); + + expect(loopbackOutbounds(settings)).toHaveLength(1); + expect( + ruleEntries(settings).filter( + (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'), + ), + ).toHaveLength(1); + }); +}); + +describe('detectBalancerCycles', () => { + const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [ + { + name: 'two-balancer loop A -> B -> A', + balancers: [ + { tag: 'A', fallbackTag: '_bl_B' }, + { tag: 'B', fallbackTag: '_bl_A' }, + ], + expected: [ + ['A', 'B'], + ['B', 'A'], + ], + }, + { + name: 'self loop A -> A', + balancers: [{ tag: 'A', fallbackTag: '_bl_A' }], + expected: [['A', 'A']], + }, + { + name: 'three-balancer loop A -> B -> C -> A', + balancers: [ + { tag: 'A', fallbackTag: '_bl_B' }, + { tag: 'B', fallbackTag: '_bl_C' }, + { tag: 'C', fallbackTag: '_bl_A' }, + ], + expected: [ + ['A', 'B'], + ['B', 'C'], + ['C', 'A'], + ], + }, + { + name: 'linear chain is not a cycle', + balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }], + expected: [], + }, + { + name: 'non-loopback fallback is ignored', + balancers: [{ tag: 'A', fallbackTag: 'direct' }], + expected: [], + }, + ]; + + it.each(cases)('$name', ({ balancers, expected }) => { + expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected); + }); +}); + +describe('propagateBalancerTagRename', () => { + it('rewrites the loopback outbound, its rule and referring fallback tags', () => { + const settings = makeSettings({ + outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }], + rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }], + balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }], + }); + + propagateBalancerTagRename(settings, 'old', 'new'); + + const [outbound] = loopbackOutbounds(settings); + expect(outbound.tag).toBe('_bl_new'); + expect(outbound.settings?.inboundTag).toBe('_bl_new'); + expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']); + expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new'); + }); + + it('leaves unrelated loopback tags untouched', () => { + const settings = makeSettings({ + outbounds: [{ tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } }], + rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }], + balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }], + }); + + propagateBalancerTagRename(settings, 'old', 'new'); + + expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other'); + expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']); + expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other'); + }); +}); + +describe('orphan cleanup', () => { + it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => { + const settings = makeSettings({ + outbounds: [ + { tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } }, + { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }, + { tag: 'proxy', protocol: 'vless' }, + ], + rules: [ + { type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' }, + { type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }, + ], + balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], + }); + + cleanupOrphanedBalancerLoopbacks(settings); + + expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']); + expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]); + }); + + it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => { + const settings = makeSettings({ + outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }], + rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], + balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], + }); + + removeBalancerLoopbackIfOrphaned(settings, 'kept'); + + expect(loopbackOutbounds(settings)).toHaveLength(1); + expect(ruleEntries(settings)).toHaveLength(1); + }); + + it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => { + const settings = makeSettings({ + outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }], + rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], + balancers: [], + }); + + removeBalancerLoopbackIfOrphaned(settings, 'kept'); + + expect(loopbackOutbounds(settings)).toHaveLength(0); + expect(ruleEntries(settings)).toHaveLength(0); + }); + + it('removeBalancerLoopback deletes the outbound and rule directly', () => { + const settings = makeSettings({ + outbounds: [ + { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }, + { tag: 'proxy', protocol: 'vless' }, + ], + rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], + balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], + }); + + removeBalancerLoopback(settings, 'kept'); + + expect(outboundTags(settings)).toEqual(['proxy']); + expect(ruleEntries(settings)).toHaveLength(0); + }); +}); diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index bdb4c7ce3..7e97d8e0c 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -905,11 +905,22 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error } // OverrideBalancer forces a balancer in the running core to use the given -// outbound tag; an empty target clears the override. +// outbound tag; an empty target clears the override. When target names +// another balancer, the override resolves to the loopback outbound that +// routes traffic through the target balancer via the routing rules. func (s *XrayService) OverrideBalancer(tag, target string) error { if !s.IsXrayRunning() { return errors.New("xray is not running") } + if target != "" { + resolved, err := s.resolveOverrideTarget(target) + if err != nil { + return err + } + if resolved != "" { + target = resolved + } + } if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil { return err } @@ -917,6 +928,42 @@ func (s *XrayService) OverrideBalancer(tag, target string) error { return s.xrayAPI.SetBalancerTarget(tag, target) } +// resolveOverrideTarget checks if target names a balancer and, if so, +// returns the loopback outbound tag that routes to it through the +// routing rules. Returns empty if target is already a concrete outbound. +func (s *XrayService) resolveOverrideTarget(target string) (string, error) { + template, err := s.settingService.GetXrayConfigTemplate() + if err != nil { + return "", err + } + var cfg map[string]any + if err := json.Unmarshal([]byte(template), &cfg); err != nil { + return "", err + } + routing, _ := cfg["routing"].(map[string]any) + if routing == nil { + return "", nil + } + rules, _ := routing["rules"].([]any) + for _, r := range rules { + rule, ok := r.(map[string]any) + if !ok { + continue + } + if rule["balancerTag"] != target { + continue + } + inboundTags, ok := rule["inboundTag"].([]any) + if !ok || len(inboundTags) == 0 { + continue + } + if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") { + return lbTag, nil + } + } + return "", nil +} + // TestRoute asks the running core which outbound its router picks for the // described connection. func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 391ebe8d2..2fc21cebd 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "وسم موازن فريد", "selector": "المحدد", "fallback": "Fallback", + "cycleTooltip": "حلقة: {path} → (العودة إلى {start})", "expected": "المتوقع", "expectedPlaceholder": "العدد الأمثل للعقد", "maxRtt": "أقصى RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.", "costMatch": "نمط الوسم", "costValue": "الوزن", - "costRegexp": "مطابقة تعبير نمطي" + "costRegexp": "مطابقة تعبير نمطي", + "balancerDeleteInUse": "لا يمكن حذف هذا الموزان — يتم استخدامه كبديل لـ: {names}", + "balancerFallbackCycle": "لا يمكن تعيين هذا الموزان كبديل — سيؤدي ذلك إلى إنشاء تبعية دائرية.", + "balancerFallbackInfo": "سيتم توجيه حركة المرور عبر: الموزان → Loopback → الخادم → الموزان المستهدف → الاتصال الخارجي. سيؤدي هذا إلى إضافة قفزة إضافية عبر الخادم، مما قد يسبب تأخيرات طفيفة.", + "fallbackBalancerHint": "اختر موزان أحمال آخر كبديل", + "reservedPrefix": "البادئة _bl_ محجوزة لكائنات loopback الداخلية للموازنات" }, "wireguard": { "secretKey": "المفتاح السري", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index c456da2c8..bdbac3a55 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1872,6 +1872,12 @@ "tagPlaceholder": "unique balancer tag", "selector": "Selector", "fallback": "Fallback", + "fallbackBalancerHint": "Select another balancer as fallback", + "balancerFallbackInfo": "Traffic will be routed through: Balancer → Loopback → Server → Target Balancer → Outbound. This adds an extra hop through the server, which may introduce slight delays.", + "balancerFallbackCycle": "Cannot set this balancer as fallback — it would create a circular dependency.", + "balancerDeleteInUse": "Cannot delete this balancer — it is used as fallback by: {names}", + "reservedPrefix": "_bl_ prefix is reserved for internal balancer loopback objects", + "cycleTooltip": "Cycle: {path} → (back to {start})", "expected": "Expected", "expectedPlaceholder": "optimal node count", "maxRtt": "Max RTT", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 4437e1111..db754ff25 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "etiqueta única de balanceador", "selector": "Selector", "fallback": "Fallback", + "cycleTooltip": "Ciclo: {path} → (volver a {start})", "expected": "Esperado", "expectedPlaceholder": "número óptimo de nodos", "maxRtt": "Máx. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.", "costMatch": "Patrón de etiqueta", "costValue": "Peso", - "costRegexp": "Coincidencia por expresión regular" + "costRegexp": "Coincidencia por expresión regular", + "balancerDeleteInUse": "No se puede eliminar este balanceador — se usa como respaldo para: {names}", + "balancerFallbackCycle": "No se puede establecer este balanceador como respaldo — crearía una dependencia circular.", + "balancerFallbackInfo": "El tráfico se enrutará a través de: Balanceador → Loopback → Servidor → Balanceador destino → Conexión saliente. Esto agrega un salto adicional a través del servidor, lo que puede introducir ligeros retrasos.", + "fallbackBalancerHint": "Seleccione otro balanceador como respaldo", + "reservedPrefix": "El prefijo _bl_ está reservado para objetos loopback internos del balanceador" }, "wireguard": { "secretKey": "Llave secreta", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 91f8294c7..87a419e42 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "تگ منحصربه‌فرد بالانسر", "selector": "انتخابگر", "fallback": "Fallback", + "cycleTooltip": "حلقه: {path} → (بازگشت به {start})", "expected": "مورد انتظار", "expectedPlaceholder": "تعداد نود بهینه", "maxRtt": "حداکثر RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.", "costMatch": "الگوی برچسب", "costValue": "وزن", - "costRegexp": "تطبیق با عبارت باقاعده" + "costRegexp": "تطبیق با عبارت باقاعده", + "balancerDeleteInUse": "امکان حذف این بالانسر وجود ندارد — به عنوان پشتیبان برای موارد زیر استفاده می‌شود: {names}", + "balancerFallbackCycle": "امکان تنظیم این بالانسر به عنوان پشتیبان وجود ندارد — وابستگی دایره‌ای ایجاد می‌کند.", + "balancerFallbackInfo": "ترافیک از مسیر زیر مسیریابی می‌شود: بالانسر → Loopback → سرور → بالانسر مقصد → اتصال خروجی. این یک پرش اضافی از طریق سرور اضافه می‌کند که ممکن است تأخیرهای جزئی ایجاد کند.", + "fallbackBalancerHint": "یک بالانسر دیگر به عنوان پشتیبان انتخاب کنید", + "reservedPrefix": "پیشوند _bl_ برای اشیاء loopback داخلی بالانسر رزرو شده است" }, "wireguard": { "secretKey": "کلید شخصی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 9e0425510..449653372 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "tag balancer unik", "selector": "Selector", "fallback": "Fallback", + "cycleTooltip": "Siklus: {path} → (kembali ke {start})", "expected": "Diharapkan", "expectedPlaceholder": "jumlah node optimal", "maxRtt": "Maks. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.", "costMatch": "Pola tag", "costValue": "Bobot", - "costRegexp": "Pencocokan ekspresi reguler" + "costRegexp": "Pencocokan ekspresi reguler", + "balancerDeleteInUse": "Tidak dapat menghapus load balancer ini — digunakan sebagai fallback untuk: {names}", + "balancerFallbackCycle": "Tidak dapat mengatur load balancer ini sebagai fallback — akan menciptakan dependensi siklik.", + "balancerFallbackInfo": "Lalu lintas akan di-routing melalui: Load Balancer → Loopback → Server → Load Balancer tujuan → Koneksi keluar. Ini menambahkan hop tambahan melalui server, yang dapat menyebabkan sedikit penundaan.", + "fallbackBalancerHint": "Pilih load balancer lain sebagai fallback", + "reservedPrefix": "Awalan _bl_ dicadangkan untuk objek loopback internal load balancer" }, "wireguard": { "secretKey": "Kunci Rahasia", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 4b5688e57..4d5ee72f3 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "一意のバランサータグ", "selector": "セレクター", "fallback": "Fallback", + "cycleTooltip": "循環: {path} → ({start} に戻る)", "expected": "期待値", "expectedPlaceholder": "最適ノード数", "maxRtt": "最大 RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。", "costMatch": "タグパターン", "costValue": "重み", - "costRegexp": "正規表現で一致" + "costRegexp": "正規表現で一致", + "balancerDeleteInUse": "このバランサーを削除できません — 以下のバランサーのフォールバックとして使用されています:{names}", + "balancerFallbackCycle": "このバランサーをフォールバックに設定できません — 循環依存が発生します。", + "balancerFallbackInfo": "トラフィックは以下のルートで転送されます:バランサー → Loopback → サーバー → ターゲットバランサー → アウトバウンド。これによりサーバーを経由する追加ホップが発生し、多少の遅延が生じる場合があります。", + "fallbackBalancerHint": "フォールバックとして別のバランサーを選択してください", + "reservedPrefix": "プレフィックス _bl_ はバランサーの内部ループバックオブジェクト用に予約されています" }, "wireguard": { "secretKey": "シークレットキー", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index d3053a279..239257be4 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "tag única do balanceador", "selector": "Seletor", "fallback": "Fallback", + "cycleTooltip": "Ciclo: {path} → (voltar para {start})", "expected": "Esperado", "expectedPlaceholder": "número ótimo de nós", "maxRtt": "Máx. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.", "costMatch": "Padrão de tag", "costValue": "Peso", - "costRegexp": "Correspondência por expressão regular" + "costRegexp": "Correspondência por expressão regular", + "balancerDeleteInUse": "Não é possível excluir este balanceador — ele é usado como fallback para: {names}", + "balancerFallbackCycle": "Não é possível definir este balanceador como fallback — isso criaria uma dependência circular.", + "balancerFallbackInfo": "O tráfego será roteado através de: Balanceador → Loopback → Servidor → Balanceador de destino → Conexão de saída. Isso adiciona um salto extra pelo servidor, o que pode introduzir pequenos atrasos.", + "fallbackBalancerHint": "Selecione outro balanceador como fallback", + "reservedPrefix": "O prefixo _bl_ é reservado para objetos loopback internos do balanceador" }, "wireguard": { "secretKey": "Chave Secreta", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index e603d8b11..a1c1f18f9 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "уникальный тег балансировщика", "selector": "Селектор", "fallback": "Fallback", + "cycleTooltip": "Цикл: {path} → (обратно к {start})", "expected": "Ожидаемое", "expectedPlaceholder": "оптимальное число узлов", "maxRtt": "Макс. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.", "costMatch": "Шаблон тега", "costValue": "Вес", - "costRegexp": "Совпадение по регулярному выражению" + "costRegexp": "Совпадение по регулярному выражению", + "balancerDeleteInUse": "Невозможно удалить этот балансировщик — он используется как запасной для: {names}", + "balancerFallbackCycle": "Невозможно назначить этот балансировщик запасным — это создаст циклическую зависимость.", + "balancerFallbackInfo": "Трафик будет маршрутизирован через: Балансировщик → Loopback → Сервер → Целевой балансировщик → Исходящее соединение. Это добавляет дополнительный хоп через сервер, что может вызвать небольшие задержки.", + "fallbackBalancerHint": "Выберите другой балансировщик в качестве запасного", + "reservedPrefix": "Префикс _bl_ зарезервирован для внутренних loopback-объектов балансеров" }, "wireguard": { "secretKey": "Секретный ключ", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 7bf9c3a4b..4ff8417d2 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "benzersiz dengeleyici etiketi", "selector": "Seçici", "fallback": "Fallback", + "cycleTooltip": "Döngü: {path} → ({start} adresine geri dön)", "expected": "Beklenen", "expectedPlaceholder": "optimal düğüm sayısı", "maxRtt": "Maks. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.", "costMatch": "Etiket deseni", "costValue": "Ağırlık", - "costRegexp": "Düzenli ifade eşleşmesi" + "costRegexp": "Düzenli ifade eşleşmesi", + "balancerDeleteInUse": "Bu dengeleyici silinemez — şu dengeleyicilerin yedeği olarak kullanılmaktadır: {names}", + "balancerFallbackCycle": "Bu dengeleyiciyi yedek olarak ayarlayamazsınız — döngüsel bağımlılık oluşturur.", + "balancerFallbackInfo": "Trafik şu yoldan yönlendirilecektir: Dengeleyici → Loopback → Sunucu → Hedef dengeleyici → Bağlantı çıkışı. Bu, sunucu üzerinden ek bir atlama ekler ve hafif gecikmelere neden olabilir.", + "fallbackBalancerHint": "Yedek olarak başka bir dengeleyici seçin", + "reservedPrefix": " _bl_ ön eki dahili dengeleyici loopback nesneleri için ayrılmıştır" }, "wireguard": { "secretKey": "Gizli Anahtar", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index c0d127c8f..97d06fbe7 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "унікальний тег балансувальника", "selector": "Селектор", "fallback": "Fallback", + "cycleTooltip": "Цикл: {path} → (назад до {start})", "expected": "Очікуване", "expectedPlaceholder": "оптимальна кількість вузлів", "maxRtt": "Макс. RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.", "costMatch": "Шаблон тегу", "costValue": "Вага", - "costRegexp": "Збіг за регулярним виразом" + "costRegexp": "Збіг за регулярним виразом", + "balancerDeleteInUse": "Неможливо видалити цей балансувач — він використовується як резервний для: {names}", + "balancerFallbackCycle": "Неможливо призначити цей балансувач резервним — це створить циклічну залежність.", + "balancerFallbackInfo": "Трафік буде маршрутизовано через: Балансувач → Loopback → Сервер → Цільовий балансувач → Вихідне зʼєднання. Це додає додатковий хоп через сервер, що може спричинити невеликі затримки.", + "fallbackBalancerHint": "Оберіть інший балансувач як резервний", + "reservedPrefix": "Префікс _bl_ зарезервовано для внутрішніх loopback-об'єктів балансувальника" }, "wireguard": { "secretKey": "Приватний ключ", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 2d70a1657..adb4b6bc9 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "tag balancer duy nhất", "selector": "Selector", "fallback": "Fallback", + "cycleTooltip": "Vòng lặp: {path} → (quay lại {start})", "expected": "Kỳ vọng", "expectedPlaceholder": "số node tối ưu", "maxRtt": "RTT tối đa", @@ -1764,7 +1765,12 @@ "balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.", "costMatch": "Mẫu thẻ", "costValue": "Trọng số", - "costRegexp": "Khớp biểu thức chính quy" + "costRegexp": "Khớp biểu thức chính quy", + "balancerDeleteInUse": "Không thể xóa load balancer này — nó được sử dụng làm dự phòng cho: {names}", + "balancerFallbackCycle": "Không thể đặt load balancer này làm dự phòng — sẽ tạo ra phụ thuộc vòng.", + "balancerFallbackInfo": "Lưu lượng sẽ được định tuyến qua: Load Balancer → Loopback → Máy chủ → Load Balancer mục tiêu → kết nối ra ngoài. Điều này thêm một hop bổ sung qua máy chủ, có thể gây ra độ trễ nhẹ.", + "fallbackBalancerHint": "Chọn một load balancer khác làm dự phòng", + "reservedPrefix": "Tiền tố _bl_ được dành riêng cho các đối tượng loopback nội bộ của bộ cân bằng" }, "wireguard": { "secretKey": "Khoá bí mật", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6e0613d61..5bf4a7706 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "唯一均衡器标签", "selector": "选择器", "fallback": "Fallback", + "cycleTooltip": "循环: {path} → (回到 {start})", "expected": "期望", "expectedPlaceholder": "最佳节点数", "maxRtt": "最大 RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。", "costMatch": "标签匹配模式", "costValue": "权重", - "costRegexp": "正则表达式匹配" + "costRegexp": "正则表达式匹配", + "balancerDeleteInUse": "无法删除此负载均衡器 — 它被用作以下负载均衡器的备用:{names}", + "balancerFallbackCycle": "无法将此负载均衡器设置为备用 — 这会创建循环依赖。", + "balancerFallbackInfo": "流量将通过以下路径路由:负载均衡器 → Loopback → 服务器 → 目标负载均衡器 → 出站连接。这会增加一个经过服务器的额外跳转,可能会引入轻微延迟。", + "fallbackBalancerHint": "选择另一个负载均衡器作为备用", + "reservedPrefix": "_bl_ 前缀保留给内部负载均衡器回环对象" }, "wireguard": { "secretKey": "密钥", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index ec8a02482..e18199a82 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1755,6 +1755,7 @@ "tagPlaceholder": "唯一均衡器標籤", "selector": "選擇器", "fallback": "Fallback", + "cycleTooltip": "循環: {path} → (回到 {start})", "expected": "期望", "expectedPlaceholder": "最佳節點數", "maxRtt": "最大 RTT", @@ -1764,7 +1765,12 @@ "balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。", "costMatch": "標籤比對模式", "costValue": "權重", - "costRegexp": "正規表示式比對" + "costRegexp": "正規表示式比對", + "balancerDeleteInUse": "無法刪除此負載平衡器 — 它被用作以下負載平衡器的備用:{names}", + "balancerFallbackCycle": "無法將此負載平衡器設定為備用 — 這會建立循環依賴。", + "balancerFallbackInfo": "流量將透過以下路徑路由:負載平衡器 → Loopback → 伺服器 → 目標負載平衡器 → 出站連線。這會增加一個經過伺服器的額外跳轉,可能會引入輕微延遲。", + "fallbackBalancerHint": "選擇另一個負載平衡器作為備用", + "reservedPrefix": "_bl_ 前綴保留給內部負載均衡器迴圈物件" }, "wireguard": { "secretKey": "金鑰",