mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 14:16:07 +00:00
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:
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": "متصل"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "وصل"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "アップ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "РАБОТАЕТ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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İÇİ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "ДОСТУПНО"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "恢复"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "恢復"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user