feat(balancer): add balancer-to-balancer fallback support

Xray does not natively support using a balancer as fallbackTag for
another balancer. This feature automates the loopback workaround:
when a user selects a balancer as fallback, the panel generates a
loopback outbound + routing rule in the template.

How it works:
- User picks fallback balancer from dropdown
- Panel creates loopback outbound _bl_{target} + routing rule
- Balancer fallbackTag set to _bl_{target}
- Traffic: Balancer A → loopback _bl_B → routing rule → Balancer B

Key features:
- Dedup: multiple balancers sharing same fallback reuse one loopback
- DFS cycle detection at edit time and on save
- Self-reference guard (cannot select own balancer)
- Delete protection (blocks if used as fallback by others)
- Cleans up routing rules referencing deleted balancers
- Override resolves balancer tags through loopback mechanism
- All live status tags resolved for display
- Internal _bl_ objects filtered from Outbounds/Routing UI
- Backward-compatible with old _bl_ naming format
- Translations for all 13 locales
This commit is contained in:
Ssentiago
2026-06-26 09:15:37 +03:00
parent 60c54827aa
commit 56d5825cb5
23 changed files with 611 additions and 65 deletions
+13
View File
@@ -30,6 +30,7 @@ import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing'; import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds'; import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers'; import { BalancersTab } from './balancers';
import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback';
import { DnsTab } from './dns'; import { DnsTab } from './dns';
import { WarpModal, NordModal } from './overrides'; import { WarpModal, NordModal } from './overrides';
import './XrayPage.css'; import './XrayPage.css';
@@ -190,6 +191,18 @@ export default function XrayPage() {
navigate('/xray#advanced'); navigate('/xray#advanced');
return; 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;
}
setTemplateSettings(clone);
}
saveAll(); saveAll();
} }
@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { import {
BalancerFormSchema, BalancerFormSchema,
type BalancerFormValues, type BalancerFormValues,
@@ -12,7 +13,9 @@ import {
BalancerStrategyTypeSchema, BalancerStrategyTypeSchema,
type BalancerStrategySettings, type BalancerStrategySettings,
type BalancerStrategyType, type BalancerStrategyType,
type BalancerObject,
} from '@/schemas/routing'; } from '@/schemas/routing';
import { isBalancerLoopbackTag } from './balancer-loopback';
export type BalancerFormValue = BalancerFormValues; export type BalancerFormValue = BalancerFormValues;
@@ -20,6 +23,9 @@ interface BalancerFormModalProps {
open: boolean; open: boolean;
balancer: BalancerFormValue | null; balancer: BalancerFormValue | null;
outboundTags: string[]; outboundTags: string[];
balancerTags: string[];
balancers: BalancerObject[];
templateSettings: XraySettingsValue | null;
otherTags: string[]; otherTags: string[];
onClose: () => void; onClose: () => void;
onConfirm: (value: BalancerFormValue) => void; onConfirm: (value: BalancerFormValue) => void;
@@ -62,6 +68,9 @@ export default function BalancerFormModal({
open, open,
balancer, balancer,
outboundTags, outboundTags,
balancerTags,
balancers,
templateSettings,
otherTags, otherTags,
onClose, onClose,
onConfirm, onConfirm,
@@ -125,9 +134,79 @@ export default function BalancerFormModal({
const baselines = settings?.baselines ?? []; const baselines = settings?.baselines ?? [];
const costs = settings?.costs ?? []; const costs = settings?.costs ?? [];
const fallbackOptions = useMemo( const currentTag = state.tag.trim();
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
[outboundTags, t], const availableBalancerTags = useMemo(() => {
return 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<string, string> = {};
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<string, string[]> = {};
for (const tg of availableBalancerTags) {
const visited = new Set<string>();
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[state.fallbackTag];
const fallbackOptions = useMemo(() => {
const options: Array<{ value: string; label: React.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: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
{tg}
</span>
),
});
}
return options;
}, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]);
const isFallbackBalancer = useMemo(
() => balancerTags.includes(state.fallbackTag),
[balancerTags, state.fallbackTag],
); );
const title = isEdit const title = isEdit
@@ -141,6 +220,7 @@ export default function BalancerFormModal({
title={title} title={title}
okText={okText} okText={okText}
cancelText={t('close')} cancelText={t('close')}
okButtonProps={{ disabled: !parsed.success || duplicateTag || wouldCreateCycle }}
mask={{ closable: false }} mask={{ closable: false }}
onOk={submit} onOk={submit}
onCancel={onClose} onCancel={onClose}
@@ -189,6 +269,22 @@ export default function BalancerFormModal({
options={fallbackOptions} options={fallbackOptions}
/> />
</Form.Item> </Form.Item>
{isFallbackBalancer && !wouldCreateCycle && (
<Alert
type="info"
showIcon
message={t('pages.xray.balancer.balancerFallbackInfo')}
style={{ marginBottom: 16 }}
/>
)}
{wouldCreateCycle && (
<Alert
type="error"
showIcon
message={t('pages.xray.balancer.balancerFallbackCycle')}
style={{ marginBottom: 16 }}
/>
)}
{state.strategy === 'leastLoad' && ( {state.strategy === 'leastLoad' && (
<> <>
@@ -1,12 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip } from 'antd'; import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip, message } from 'antd';
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'; import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal'; import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal'; import type { BalancerFormValue } from './BalancerFormModal';
import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers'; import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
import {
isBalancerLoopbackTag,
loopbackTagFor,
resolveLoopbackFallback,
ensureBalancerLoopback,
removeBalancerLoopback,
removeBalancerLoopbackIfOrphaned,
propagateBalancerTagRename,
} from './balancer-loopback';
import ObservatorySettingsTab from './ObservatorySettingsTab'; import ObservatorySettingsTab from './ObservatorySettingsTab';
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
@@ -42,6 +51,7 @@ interface BalancerRow {
strategy: BalancerStrategyType; strategy: BalancerStrategyType;
selector: string[]; selector: string[];
fallbackTag: string; fallbackTag: string;
displayFallbackTag: string;
settings?: BalancerStrategySettings; settings?: BalancerStrategySettings;
} }
@@ -61,26 +71,33 @@ export default function BalancersTab({
}: BalancersTabProps) { }: BalancersTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null); const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const balancerObjects = useMemo(
() => (templateSettings?.routing?.balancers || []) as BalancerObject[],
[templateSettings?.routing?.balancers],
);
const rows: BalancerRow[] = useMemo(() => { const rows: BalancerRow[] = useMemo(() => {
const list = (templateSettings?.routing?.balancers || []) as BalancerRecord[]; const list = balancerObjects;
return list.map((b, idx) => ({ return list.map((b, idx) => ({
key: idx, key: idx,
tag: b.tag || '', tag: b.tag || '',
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType, strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
selector: b.selector || [], selector: b.selector || [],
fallbackTag: b.fallbackTag || '', fallbackTag: b.fallbackTag || '',
displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
settings: b.strategy?.settings, settings: b.strategy?.settings,
})); }));
}, [templateSettings?.routing?.balancers]); }, [balancerObjects, templateSettings]);
const outboundTags = useMemo(() => { const outboundTags = useMemo(() => {
const tags = new Set<string>(); const tags = new Set<string>();
for (const o of templateSettings?.outbounds || []) { for (const o of templateSettings?.outbounds || []) {
if (o?.tag) tags.add(o.tag); if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag);
} }
for (const tag of clientReverseTags || []) { for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag); if (tag) tags.add(tag);
@@ -96,6 +113,14 @@ export default function BalancersTab({
return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean); return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean);
}, [rows, editingIndex]); }, [rows, editingIndex]);
const balancerTags = useMemo(() => {
return otherTags.filter((tg) => !isBalancerLoopbackTag(tg));
}, [otherTags]);
const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => {
return outboundTags.map((tag) => ({ value: tag, label: tag }));
}, [outboundTags]);
const mutate = useCallback( const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => { (mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => { setTemplateSettings((prev) => {
@@ -146,7 +171,12 @@ export default function BalancersTab({
setModalOpen(true); setModalOpen(true);
} }
function openEdit(idx: number) { function openEdit(idx: number) {
setEditingBalancer(rows[idx]); const row = rows[idx];
const resolved: BalancerFormValue = {
...row,
fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
};
setEditingBalancer(resolved);
setEditingIndex(idx); setEditingIndex(idx);
setModalOpen(true); setModalOpen(true);
} }
@@ -156,10 +186,11 @@ export default function BalancersTab({
if (!tt.routing) tt.routing = { rules: [], balancers: [] }; if (!tt.routing) tt.routing = { rules: [], balancers: [] };
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = []; if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
const list = tt.routing.balancers as BalancerRecord[]; const list = tt.routing.balancers as BalancerRecord[];
const wire: BalancerRecord = { const wire: BalancerRecord = {
tag: form.tag, tag: form.tag,
selector: [...form.selector], selector: [...form.selector],
fallbackTag: form.fallbackTag || '', fallbackTag: '',
}; };
if (form.strategy && form.strategy !== 'random') { if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy }; wire.strategy = { type: form.strategy };
@@ -167,16 +198,42 @@ export default function BalancersTab({
wire.strategy.settings = form.settings; wire.strategy.settings = form.settings;
} }
} }
const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag);
if (isFallbackABalancer) {
wire.fallbackTag = loopbackTagFor(form.fallbackTag);
} else {
wire.fallbackTag = form.fallbackTag || '';
}
if (editingIndex == null) { if (editingIndex == null) {
list.push(wire); list.push(wire);
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
} else { } else {
const oldTag = list[editingIndex]?.tag; const oldTag = list[editingIndex]?.tag;
const oldFallback = list[editingIndex]?.fallbackTag || '';
list[editingIndex] = wire; list[editingIndex] = wire;
if (oldTag && oldTag !== wire.tag) { if (oldTag && oldTag !== wire.tag) {
const rules = tt.routing.rules || []; const rules = tt.routing.rules || [];
for (const rule of rules) { for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag; if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
} }
propagateBalancerTagRename(tt, oldTag, wire.tag);
}
const oldTarget = isBalancerLoopbackTag(oldFallback)
? (oldFallback.slice(4))
: null;
if (oldTarget && oldTarget !== form.fallbackTag) {
removeBalancerLoopbackIfOrphaned(tt, oldTarget);
}
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
} }
} }
syncObservatories(tt); syncObservatories(tt);
@@ -185,6 +242,15 @@ export default function BalancersTab({
} }
function confirmDelete(idx: number) { function confirmDelete(idx: number) {
const deletedTag = rows[idx]?.tag;
const lbTag = loopbackTagFor(deletedTag);
const dependents = (templateSettings?.routing?.balancers || [])
.filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
.map((b) => b.tag);
if (dependents.length > 0) {
messageApi.error(t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }));
return;
}
const removed = templateSettings const removed = templateSettings
? observersRemovedByDeletingBalancer(templateSettings, idx) ? observersRemovedByDeletingBalancer(templateSettings, idx)
: { observatory: false, burst: false }; : { observatory: false, burst: false };
@@ -199,7 +265,14 @@ export default function BalancersTab({
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: () => mutate((tt) => { onOk: () => mutate((tt) => {
if (tt.routing?.balancers) { if (tt.routing?.balancers) {
const deletedTag = tt.routing.balancers[idx]?.tag;
removeBalancerLoopback(tt, deletedTag);
tt.routing.balancers.splice(idx, 1); tt.routing.balancers.splice(idx, 1);
if (tt.routing?.rules) {
tt.routing.rules = tt.routing.rules.filter(
(r) => r?.balancerTag !== deletedTag,
);
}
syncObservatories(tt); syncObservatories(tt);
} }
}), }),
@@ -278,7 +351,7 @@ export default function BalancersTab({
</Tag> </Tag>
)), )),
}, },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 }, { title: 'Fallback', dataIndex: 'displayFallbackTag', key: 'displayFallbackTag', align: 'center', width: 160 },
{ {
title: t('pages.xray.balancerLive'), title: t('pages.xray.balancerLive'),
key: 'live', key: 'live',
@@ -293,9 +366,13 @@ export default function BalancersTab({
</Tooltip> </Tooltip>
); );
} }
const picked = live.override || live.selected?.[0] || record.fallbackTag; const resolve = (tag: string) => isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
const picked = live.override ? resolve(live.override) : live.selected?.[0] ? resolve(live.selected[0]) : record.displayFallbackTag;
const tooltipText = live.override
? resolve(live.override)
: (live.selected || []).map(resolve).join(', ');
return ( return (
<Tooltip title={(live.selected || []).join(', ') || undefined}> <Tooltip title={tooltipText || undefined}>
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag> <Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
</Tooltip> </Tooltip>
); );
@@ -308,6 +385,23 @@ export default function BalancersTab({
width: 200, width: 200,
render: (_v, record) => { render: (_v, record) => {
const live = liveStatus[record.tag]; const live = liveStatus[record.tag];
const resolvedFB = record.displayFallbackTag;
let options = overrideOptions;
if (resolvedFB && !outboundTags.includes(resolvedFB)) {
options = [...overrideOptions, {
value: resolvedFB,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
{resolvedFB}
</span>
),
}];
}
const rawOverride = live?.override || undefined;
const resolvedOverride = rawOverride && isBalancerLoopbackTag(rawOverride)
? resolveLoopbackFallback(templateSettings!, rawOverride)
: rawOverride;
return ( return (
<Select <Select
size="small" size="small"
@@ -315,8 +409,8 @@ export default function BalancersTab({
placeholder={t('pages.xray.balancerOverridePh')} placeholder={t('pages.xray.balancerOverridePh')}
allowClear allowClear
disabled={!live?.running} disabled={!live?.running}
value={live?.override || undefined} value={resolvedOverride}
options={outboundTags.map((tag) => ({ label: tag, value: tag }))} options={options}
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')} onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
/> />
); );
@@ -359,6 +453,7 @@ export default function BalancersTab({
return ( return (
<> <>
{modalContextHolder} {modalContextHolder}
{messageContextHolder}
<Tabs <Tabs
items={[ items={[
{ {
@@ -385,6 +480,9 @@ export default function BalancersTab({
open={modalOpen} open={modalOpen}
balancer={editingBalancer} balancer={editingBalancer}
outboundTags={outboundTags} outboundTags={outboundTags}
balancerTags={balancerTags}
balancers={balancerObjects}
templateSettings={templateSettings}
otherTags={otherTags} otherTags={otherTags}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onConfirm={onConfirm} onConfirm={onConfirm}
@@ -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<string, unknown>[])[existingIdx] = newOutbound;
} else {
(settings.outbounds as Record<string, unknown>[]).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<string, unknown>[])[existingRuleIdx].balancerTag = targetBalancerTag;
} else {
(settings.routing.rules as Record<string, unknown>[]).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 as Array<{ inboundTag?: string[] }>).filter(
(r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)),
) as XraySettingsValue['routing'] extends { rules?: infer R } ? R : never;
}
}
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<string>();
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);
}
}
@@ -330,6 +330,10 @@ export default function OutboundFormModal({
messageApi.error(t('pages.xray.outboundForm.tagRequired')); messageApi.error(t('pages.xray.outboundForm.tagRequired'));
return; return;
} }
if (tagValue.startsWith('_bl_')) {
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
return;
}
const isDuplicateTag = (existingTags || []).includes(tagValue) const isDuplicateTag = (existingTags || []).includes(tagValue)
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue); && !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
if (isDuplicateTag) { if (isDuplicateTag) {
@@ -43,6 +43,7 @@ import TextModal from '@/components/feedback/TextModal';
import OutboundFormModal from './OutboundFormModal'; import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers'; import { propagateOutboundTagRename } from '../basics/helpers';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css'; import './OutboundsTab.css';
@@ -140,7 +141,13 @@ export default function OutboundsTab({
[templateSettings?.outbounds], [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 dialerProxyTags = useMemo(() => {
const tags = new Set<string>(); const tags = new Set<string>();
+27 -21
View File
@@ -14,6 +14,7 @@ import {
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
import PromptModal from '@/components/feedback/PromptModal'; import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal'; import TextModal from '@/components/feedback/TextModal';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import RoutingBasic from './RoutingBasic'; import RoutingBasic from './RoutingBasic';
import RouteTester from './RouteTester'; import RouteTester from './RouteTester';
import RuleFormModal from './RuleFormModal'; import RuleFormModal from './RuleFormModal';
@@ -66,26 +67,31 @@ export default function RoutingTab({
const rows: RuleRow[] = useMemo( const rows: RuleRow[] = useMemo(
() => () =>
rules.map((rule, idx) => { rules
const r: RuleRow = { key: idx }; .map((rule, idx) => {
r.enabled = rule.enabled !== false; const r: RuleRow = { key: idx };
r.domain = arrJoin(rule.domain); r.enabled = rule.enabled !== false;
r.ip = arrJoin(rule.ip); r.domain = arrJoin(rule.domain);
r.port = rule.port; r.ip = arrJoin(rule.ip);
r.sourcePort = rule.sourcePort; r.port = rule.port;
r.vlessRoute = rule.vlessRoute; r.sourcePort = rule.sourcePort;
r.network = rule.network; r.vlessRoute = rule.vlessRoute;
r.sourceIP = arrJoin(rule.sourceIP); r.network = rule.network;
r.user = arrJoin(rule.user); r.sourceIP = arrJoin(rule.sourceIP);
r.inboundTag = arrJoin(rule.inboundTag); r.user = arrJoin(rule.user);
r.protocol = arrJoin(rule.protocol); r.inboundTag = arrJoin(rule.inboundTag);
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) { r.protocol = arrJoin(rule.protocol);
r.attrs = JSON.stringify(rule.attrs, null, 2); 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; r.outboundTag = rule.outboundTag;
return r; r.balancerTag = rule.balancerTag;
}), return r;
})
.filter((r) => {
const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
return !inboundTags.some(isBalancerLoopbackTag);
}),
[rules], [rules],
); );
@@ -105,7 +111,7 @@ export default function RoutingTab({
const seen = new Set<string>(); const seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
const push = (tag?: string) => { const push = (tag?: string) => {
if (!tag || seen.has(tag)) return; if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return;
seen.add(tag); seen.add(tag);
out.push(tag); out.push(tag);
}; };
+9 -2
View File
@@ -101,7 +101,10 @@ export const RuleFormSchema = z.object({
}); });
export const BalancerFormSchema = 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'), strategy: BalancerStrategyTypeSchema.default('random'),
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'), selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
fallbackTag: z.string().default(''), fallbackTag: z.string().default(''),
@@ -111,7 +114,11 @@ export const BalancerFormSchema = z.object({
export const OutboundTagSchema = z export const OutboundTagSchema = z
.string() .string()
.trim() .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<typeof BalancerFormSchema>; export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
export type RuleFormValues = z.infer<typeof RuleFormSchema>; export type RuleFormValues = z.infer<typeof RuleFormSchema>;
@@ -10,6 +10,9 @@ function renderModal(onConfirm = vi.fn()) {
open open
balancer={null} balancer={null}
outboundTags={['proxy', 'direct']} outboundTags={['proxy', 'direct']}
balancerTags={[]}
balancers={[]}
templateSettings={null}
otherTags={['existing']} otherTags={['existing']}
onClose={() => {}} onClose={() => {}}
onConfirm={onConfirm} onConfirm={onConfirm}
+49 -1
View File
@@ -906,11 +906,23 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
} }
// OverrideBalancer forces a balancer in the running core to use the given // 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 { func (s *XrayService) OverrideBalancer(tag, target string) error {
if !s.IsXrayRunning() { if !s.IsXrayRunning() {
return errors.New("xray is not running") return errors.New("xray is not running")
} }
if target != "" {
resolved, err := s.resolveOverrideTarget(target)
if err != nil {
return err
}
if resolved == "" {
return errors.New("balancer fallback target not found in routing rules")
}
target = resolved
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil { if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
return err return err
} }
@@ -918,6 +930,42 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
return s.xrayAPI.SetBalancerTarget(tag, target) 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 // TestRoute asks the running core which outbound its router picks for the
// described connection. // described connection.
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) { func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "وسم موازن فريد", "tagPlaceholder": "وسم موازن فريد",
"selector": "المحدد", "selector": "المحدد",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "اختر موزان أحمال آخر كبديل",
"balancerFallbackInfo": "سيتم توجيه حركة المرور عبر: الموزان → Loopback → الخادم → الموزان المستهدف → الاتصال الخارجي. سيؤدي هذا إلى إضافة قفزة إضافية عبر الخادم، مما قد يسبب تأخيرات طفيفة.",
"balancerFallbackCycle": "لا يمكن تعيين هذا الموزان كبديل — سيؤدي ذلك إلى إنشاء تبعية دائرية.",
"balancerDeleteInUse": "لا يمكن حذف هذا الموزان — يتم استخدامه كبديل لـ: {names}",
"expected": "المتوقع", "expected": "المتوقع",
"expectedPlaceholder": "العدد الأمثل للعقد", "expectedPlaceholder": "العدد الأمثل للعقد",
"maxRtt": "أقصى RTT", "maxRtt": "أقصى RTT",
"tolerance": "التحمل", "tolerance": "التحمل",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل." "balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
"cycleTooltip": "حلقة: {path} → (العودة إلى {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "المفتاح السري", "secretKey": "المفتاح السري",
@@ -2152,4 +2157,4 @@
"statusDown": "غير متصل", "statusDown": "غير متصل",
"statusUp": "متصل" "statusUp": "متصل"
} }
} }
+7 -1
View File
@@ -1825,6 +1825,12 @@
"tagPlaceholder": "unique balancer tag", "tagPlaceholder": "unique balancer tag",
"selector": "Selector", "selector": "Selector",
"fallback": "Fallback", "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", "expected": "Expected",
"expectedPlaceholder": "optimal node count", "expectedPlaceholder": "optimal node count",
"maxRtt": "Max RTT", "maxRtt": "Max RTT",
@@ -2155,4 +2161,4 @@
"statusDown": "DOWN", "statusDown": "DOWN",
"statusUp": "UP" "statusUp": "UP"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "etiqueta única de balanceador", "tagPlaceholder": "etiqueta única de balanceador",
"selector": "Selector", "selector": "Selector",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Seleccione otro balanceador como respaldo",
"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.",
"balancerFallbackCycle": "No se puede establecer este balanceador como respaldo — crearía una dependencia circular.",
"balancerDeleteInUse": "No se puede eliminar este balanceador — se usa como respaldo para: {names}",
"expected": "Esperado", "expected": "Esperado",
"expectedPlaceholder": "número óptimo de nodos", "expectedPlaceholder": "número óptimo de nodos",
"maxRtt": "Máx. RTT", "maxRtt": "Máx. RTT",
"tolerance": "Tolerancia", "tolerance": "Tolerancia",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag." "balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
"cycleTooltip": "Ciclo: {path} → (volver a {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Llave secreta", "secretKey": "Llave secreta",
@@ -2152,4 +2157,4 @@
"statusDown": "CAÍDO", "statusDown": "CAÍDO",
"statusUp": "ACTIVO" "statusUp": "ACTIVO"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "تگ منحصربه‌فرد بالانسر", "tagPlaceholder": "تگ منحصربه‌فرد بالانسر",
"selector": "انتخابگر", "selector": "انتخابگر",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "یک بالانسر دیگر به عنوان پشتیبان انتخاب کنید",
"balancerFallbackInfo": "ترافیک از مسیر زیر مسیریابی می‌شود: بالانسر → Loopback → سرور → بالانسر مقصد → اتصال خروجی. این یک پرش اضافی از طریق سرور اضافه می‌کند که ممکن است تأخیرهای جزئی ایجاد کند.",
"balancerFallbackCycle": "امکان تنظیم این بالانسر به عنوان پشتیبان وجود ندارد — وابستگی دایره‌ای ایجاد می‌کند.",
"balancerDeleteInUse": "امکان حذف این بالانسر وجود ندارد — به عنوان پشتیبان برای موارد زیر استفاده می‌شود: {names}",
"expected": "مورد انتظار", "expected": "مورد انتظار",
"expectedPlaceholder": "تعداد نود بهینه", "expectedPlaceholder": "تعداد نود بهینه",
"maxRtt": "حداکثر RTT", "maxRtt": "حداکثر RTT",
"tolerance": "تحمل", "tolerance": "تحمل",
"baselines": "خطوط پایه", "baselines": "خطوط پایه",
"costs": "هزینه‌ها", "costs": "هزینه‌ها",
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد." "balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
"cycleTooltip": "حلقه: {path} → (بازگشت به {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "کلید شخصی", "secretKey": "کلید شخصی",
@@ -2152,4 +2157,4 @@
"statusDown": "قطع", "statusDown": "قطع",
"statusUp": "وصل" "statusUp": "وصل"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag balancer unik", "tagPlaceholder": "tag balancer unik",
"selector": "Selector", "selector": "Selector",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Pilih load balancer lain sebagai fallback",
"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.",
"balancerFallbackCycle": "Tidak dapat mengatur load balancer ini sebagai fallback — akan menciptakan dependensi siklik.",
"balancerDeleteInUse": "Tidak dapat menghapus load balancer ini — digunakan sebagai fallback untuk: {names}",
"expected": "Diharapkan", "expected": "Diharapkan",
"expectedPlaceholder": "jumlah node optimal", "expectedPlaceholder": "jumlah node optimal",
"maxRtt": "Maks. RTT", "maxRtt": "Maks. RTT",
"tolerance": "Toleransi", "tolerance": "Toleransi",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi." "balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
"cycleTooltip": "Siklus: {path} → (kembali ke {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Kunci Rahasia", "secretKey": "Kunci Rahasia",
@@ -2152,4 +2157,4 @@
"statusDown": "MATI", "statusDown": "MATI",
"statusUp": "AKTIF" "statusUp": "AKTIF"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "一意のバランサータグ", "tagPlaceholder": "一意のバランサータグ",
"selector": "セレクター", "selector": "セレクター",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "フォールバックとして別のバランサーを選択してください",
"balancerFallbackInfo": "トラフィックは以下のルートで転送されます:バランサー → Loopback → サーバー → ターゲットバランサー → アウトバウンド。これによりサーバーを経由する追加ホップが発生し、多少の遅延が生じる場合があります。",
"balancerFallbackCycle": "このバランサーをフォールバックに設定できません — 循環依存が発生します。",
"balancerDeleteInUse": "このバランサーを削除できません — 以下のバランサーのフォールバックとして使用されています:{names}",
"expected": "期待値", "expected": "期待値",
"expectedPlaceholder": "最適ノード数", "expectedPlaceholder": "最適ノード数",
"maxRtt": "最大 RTT", "maxRtt": "最大 RTT",
"tolerance": "許容範囲", "tolerance": "許容範囲",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。" "balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
"cycleTooltip": "循環: {path} → ({start} に戻る)"
}, },
"wireguard": { "wireguard": {
"secretKey": "シークレットキー", "secretKey": "シークレットキー",
@@ -2152,4 +2157,4 @@
"statusDown": "ダウン", "statusDown": "ダウン",
"statusUp": "アップ" "statusUp": "アップ"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag única do balanceador", "tagPlaceholder": "tag única do balanceador",
"selector": "Seletor", "selector": "Seletor",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Selecione outro balanceador como fallback",
"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.",
"balancerFallbackCycle": "Não é possível definir este balanceador como fallback — isso criaria uma dependência circular.",
"balancerDeleteInUse": "Não é possível excluir este balanceador — ele é usado como fallback para: {names}",
"expected": "Esperado", "expected": "Esperado",
"expectedPlaceholder": "número ótimo de nós", "expectedPlaceholder": "número ótimo de nós",
"maxRtt": "Máx. RTT", "maxRtt": "Máx. RTT",
"tolerance": "Tolerância", "tolerance": "Tolerância",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará." "balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
"cycleTooltip": "Ciclo: {path} → (voltar para {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Chave Secreta", "secretKey": "Chave Secreta",
@@ -2152,4 +2157,4 @@
"statusDown": "INATIVO", "statusDown": "INATIVO",
"statusUp": "ATIVO" "statusUp": "ATIVO"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "уникальный тег балансировщика", "tagPlaceholder": "уникальный тег балансировщика",
"selector": "Селектор", "selector": "Селектор",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Выберите другой балансировщик в качестве запасного",
"balancerFallbackInfo": "Трафик будет маршрутизирован через: Балансировщик → Loopback → Сервер → Целевой балансировщик → Исходящее соединение. Это добавляет дополнительный хоп через сервер, что может вызвать небольшие задержки.",
"balancerFallbackCycle": "Невозможно назначить этот балансировщик запасным — это создаст циклическую зависимость.",
"balancerDeleteInUse": "Невозможно удалить этот балансировщик — он используется как запасной для: {names}",
"expected": "Ожидаемое", "expected": "Ожидаемое",
"expectedPlaceholder": "оптимальное число узлов", "expectedPlaceholder": "оптимальное число узлов",
"maxRtt": "Макс. RTT", "maxRtt": "Макс. RTT",
"tolerance": "Допуск", "tolerance": "Допуск",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag." "balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
"cycleTooltip": "Цикл: {path} → (обратно к {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Секретный ключ", "secretKey": "Секретный ключ",
@@ -2152,4 +2157,4 @@
"statusDown": "НЕДОСТУПЕН", "statusDown": "НЕДОСТУПЕН",
"statusUp": "РАБОТАЕТ" "statusUp": "РАБОТАЕТ"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "benzersiz dengeleyici etiketi", "tagPlaceholder": "benzersiz dengeleyici etiketi",
"selector": "Seçici", "selector": "Seçici",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Yedek olarak başka bir dengeleyici seçin",
"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.",
"balancerFallbackCycle": "Bu dengeleyiciyi yedek olarak ayarlayamazsınız — döngüsel bağımlılık oluşturur.",
"balancerDeleteInUse": "Bu dengeleyici silinemez — şu dengeleyicilerin yedeği olarak kullanılmaktadır: {names}",
"expected": "Beklenen", "expected": "Beklenen",
"expectedPlaceholder": "optimal düğüm sayısı", "expectedPlaceholder": "optimal düğüm sayısı",
"maxRtt": "Maks. RTT", "maxRtt": "Maks. RTT",
"tolerance": "Tolerans", "tolerance": "Tolerans",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"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." "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.",
"cycleTooltip": "Döngü: {path} → ({start} adresine geri dön)"
}, },
"wireguard": { "wireguard": {
"secretKey": "Gizli Anahtar", "secretKey": "Gizli Anahtar",
@@ -2152,4 +2157,4 @@
"statusDown": "ÇEVRİMDIŞI", "statusDown": "ÇEVRİMDIŞI",
"statusUp": "ÇEVRİMİÇİ" "statusUp": "ÇEVRİMİÇİ"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "унікальний тег балансувальника", "tagPlaceholder": "унікальний тег балансувальника",
"selector": "Селектор", "selector": "Селектор",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Оберіть інший балансувач як резервний",
"balancerFallbackInfo": "Трафік буде маршрутизовано через: Балансувач → Loopback → Сервер → Цільовий балансувач → Вихідне зʼєднання. Це додає додатковий хоп через сервер, що може спричинити невеликі затримки.",
"balancerFallbackCycle": "Неможливо призначити цей балансувач резервним — це створить циклічну залежність.",
"balancerDeleteInUse": "Неможливо видалити цей балансувач — він використовується як резервний для: {names}",
"expected": "Очікуване", "expected": "Очікуване",
"expectedPlaceholder": "оптимальна кількість вузлів", "expectedPlaceholder": "оптимальна кількість вузлів",
"maxRtt": "Макс. RTT", "maxRtt": "Макс. RTT",
"tolerance": "Допуск", "tolerance": "Допуск",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag." "balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
"cycleTooltip": "Цикл: {path} → (назад до {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Приватний ключ", "secretKey": "Приватний ключ",
@@ -2152,4 +2157,4 @@
"statusDown": "НЕДОСТУПНО", "statusDown": "НЕДОСТУПНО",
"statusUp": "ДОСТУПНО" "statusUp": "ДОСТУПНО"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "tag balancer duy nhất", "tagPlaceholder": "tag balancer duy nhất",
"selector": "Selector", "selector": "Selector",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "Chọn một load balancer khác làm dự phò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ẹ.",
"balancerFallbackCycle": "Không thể đặt load balancer này làm dự phòng — sẽ tạo ra phụ thuộc vòng.",
"balancerDeleteInUse": "Không thể xóa load balancer này — nó được sử dụng làm dự phòng cho: {names}",
"expected": "Kỳ vọng", "expected": "Kỳ vọng",
"expectedPlaceholder": "số node tối ưu", "expectedPlaceholder": "số node tối ưu",
"maxRtt": "RTT tối đa", "maxRtt": "RTT tối đa",
"tolerance": "Dung sai", "tolerance": "Dung sai",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"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." "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.",
"cycleTooltip": "Vòng lặp: {path} → (quay lại {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "Khoá bí mật", "secretKey": "Khoá bí mật",
@@ -2152,4 +2157,4 @@
"statusDown": "NGỪNG HOẠT ĐỘNG", "statusDown": "NGỪNG HOẠT ĐỘNG",
"statusUp": "HOẠT ĐỘNG" "statusUp": "HOẠT ĐỘNG"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "唯一均衡器标签", "tagPlaceholder": "唯一均衡器标签",
"selector": "选择器", "selector": "选择器",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "选择另一个负载均衡器作为备用",
"balancerFallbackInfo": "流量将通过以下路径路由:负载均衡器 → Loopback → 服务器 → 目标负载均衡器 → 出站连接。这会增加一个经过服务器的额外跳转,可能会引入轻微延迟。",
"balancerFallbackCycle": "无法将此负载均衡器设置为备用 — 这会创建循环依赖。",
"balancerDeleteInUse": "无法删除此负载均衡器 — 它被用作以下负载均衡器的备用:{names}",
"expected": "期望", "expected": "期望",
"expectedPlaceholder": "最佳节点数", "expectedPlaceholder": "最佳节点数",
"maxRtt": "最大 RTT", "maxRtt": "最大 RTT",
"tolerance": "容差", "tolerance": "容差",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。" "balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
"cycleTooltip": "循环: {path} → (回到 {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "密钥", "secretKey": "密钥",
@@ -2152,4 +2157,4 @@
"statusDown": "断开", "statusDown": "断开",
"statusUp": "恢复" "statusUp": "恢复"
} }
} }
+7 -2
View File
@@ -1706,13 +1706,18 @@
"tagPlaceholder": "唯一均衡器標籤", "tagPlaceholder": "唯一均衡器標籤",
"selector": "選擇器", "selector": "選擇器",
"fallback": "Fallback", "fallback": "Fallback",
"fallbackBalancerHint": "選擇另一個負載平衡器作為備用",
"balancerFallbackInfo": "流量將透過以下路徑路由:負載平衡器 → Loopback → 伺服器 → 目標負載平衡器 → 出站連線。這會增加一個經過伺服器的額外跳轉,可能會引入輕微延遲。",
"balancerFallbackCycle": "無法將此負載平衡器設定為備用 — 這會建立循環依賴。",
"balancerDeleteInUse": "無法刪除此負載平衡器 — 它被用作以下負載平衡器的備用:{names}",
"expected": "期望", "expected": "期望",
"expectedPlaceholder": "最佳節點數", "expectedPlaceholder": "最佳節點數",
"maxRtt": "最大 RTT", "maxRtt": "最大 RTT",
"tolerance": "容差", "tolerance": "容差",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。" "balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
"cycleTooltip": "循環: {path} → (回到 {start})"
}, },
"wireguard": { "wireguard": {
"secretKey": "金鑰", "secretKey": "金鑰",
@@ -2152,4 +2157,4 @@
"statusDown": "中斷", "statusDown": "中斷",
"statusUp": "恢復" "statusUp": "恢復"
} }
} }