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 { OutboundsTab } from './outbounds';
|
||||
import { BalancersTab } from './balancers';
|
||||
import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback';
|
||||
import { DnsTab } from './dns';
|
||||
import { WarpModal, NordModal } from './overrides';
|
||||
import './XrayPage.css';
|
||||
@@ -190,6 +191,18 @@ export default function XrayPage() {
|
||||
navigate('/xray#advanced');
|
||||
return;
|
||||
}
|
||||
if (templateSettings) {
|
||||
const clone = JSON.parse(JSON.stringify(templateSettings));
|
||||
ensureMissingBalancerLoopbacks(clone);
|
||||
cleanupOrphanedBalancerLoopbacks(clone);
|
||||
const cycles = detectBalancerCycles(clone);
|
||||
if (cycles.length > 0) {
|
||||
const names = cycles.map((c) => c.join(' → ')).join(', ');
|
||||
messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
|
||||
return;
|
||||
}
|
||||
setTemplateSettings(clone);
|
||||
}
|
||||
saveAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
BalancerFormSchema,
|
||||
type BalancerFormValues,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
BalancerStrategyTypeSchema,
|
||||
type BalancerStrategySettings,
|
||||
type BalancerStrategyType,
|
||||
type BalancerObject,
|
||||
} from '@/schemas/routing';
|
||||
import { isBalancerLoopbackTag } from './balancer-loopback';
|
||||
|
||||
export type BalancerFormValue = BalancerFormValues;
|
||||
|
||||
@@ -20,6 +23,9 @@ interface BalancerFormModalProps {
|
||||
open: boolean;
|
||||
balancer: BalancerFormValue | null;
|
||||
outboundTags: string[];
|
||||
balancerTags: string[];
|
||||
balancers: BalancerObject[];
|
||||
templateSettings: XraySettingsValue | null;
|
||||
otherTags: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (value: BalancerFormValue) => void;
|
||||
@@ -62,6 +68,9 @@ export default function BalancerFormModal({
|
||||
open,
|
||||
balancer,
|
||||
outboundTags,
|
||||
balancerTags,
|
||||
balancers,
|
||||
templateSettings,
|
||||
otherTags,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -125,9 +134,79 @@ export default function BalancerFormModal({
|
||||
const baselines = settings?.baselines ?? [];
|
||||
const costs = settings?.costs ?? [];
|
||||
|
||||
const fallbackOptions = useMemo(
|
||||
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
|
||||
[outboundTags, t],
|
||||
const currentTag = state.tag.trim();
|
||||
|
||||
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
|
||||
@@ -141,6 +220,7 @@ export default function BalancerFormModal({
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText={t('close')}
|
||||
okButtonProps={{ disabled: !parsed.success || duplicateTag || wouldCreateCycle }}
|
||||
mask={{ closable: false }}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
@@ -189,6 +269,22 @@ export default function BalancerFormModal({
|
||||
options={fallbackOptions}
|
||||
/>
|
||||
</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' && (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import BalancerFormModal from './BalancerFormModal';
|
||||
import type { BalancerFormValue } from './BalancerFormModal';
|
||||
import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
|
||||
import {
|
||||
isBalancerLoopbackTag,
|
||||
loopbackTagFor,
|
||||
resolveLoopbackFallback,
|
||||
ensureBalancerLoopback,
|
||||
removeBalancerLoopback,
|
||||
removeBalancerLoopbackIfOrphaned,
|
||||
propagateBalancerTagRename,
|
||||
} from './balancer-loopback';
|
||||
import ObservatorySettingsTab from './ObservatorySettingsTab';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -42,6 +51,7 @@ interface BalancerRow {
|
||||
strategy: BalancerStrategyType;
|
||||
selector: string[];
|
||||
fallbackTag: string;
|
||||
displayFallbackTag: string;
|
||||
settings?: BalancerStrategySettings;
|
||||
}
|
||||
|
||||
@@ -61,26 +71,33 @@ export default function BalancersTab({
|
||||
}: BalancersTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | 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 list = (templateSettings?.routing?.balancers || []) as BalancerRecord[];
|
||||
const list = balancerObjects;
|
||||
return list.map((b, idx) => ({
|
||||
key: idx,
|
||||
tag: b.tag || '',
|
||||
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
|
||||
selector: b.selector || [],
|
||||
fallbackTag: b.fallbackTag || '',
|
||||
displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
|
||||
settings: b.strategy?.settings,
|
||||
}));
|
||||
}, [templateSettings?.routing?.balancers]);
|
||||
}, [balancerObjects, templateSettings]);
|
||||
|
||||
const outboundTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
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 || []) {
|
||||
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);
|
||||
}, [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(
|
||||
(mutator: (next: XraySettingsValue) => void) => {
|
||||
setTemplateSettings((prev) => {
|
||||
@@ -146,7 +171,12 @@ export default function BalancersTab({
|
||||
setModalOpen(true);
|
||||
}
|
||||
function openEdit(idx: number) {
|
||||
setEditingBalancer(rows[idx]);
|
||||
const row = rows[idx];
|
||||
const resolved: BalancerFormValue = {
|
||||
...row,
|
||||
fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
|
||||
};
|
||||
setEditingBalancer(resolved);
|
||||
setEditingIndex(idx);
|
||||
setModalOpen(true);
|
||||
}
|
||||
@@ -156,10 +186,11 @@ export default function BalancersTab({
|
||||
if (!tt.routing) tt.routing = { rules: [], balancers: [] };
|
||||
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
|
||||
const list = tt.routing.balancers as BalancerRecord[];
|
||||
|
||||
const wire: BalancerRecord = {
|
||||
tag: form.tag,
|
||||
selector: [...form.selector],
|
||||
fallbackTag: form.fallbackTag || '',
|
||||
fallbackTag: '',
|
||||
};
|
||||
if (form.strategy && form.strategy !== 'random') {
|
||||
wire.strategy = { type: form.strategy };
|
||||
@@ -167,16 +198,42 @@ export default function BalancersTab({
|
||||
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) {
|
||||
list.push(wire);
|
||||
if (isFallbackABalancer) {
|
||||
ensureBalancerLoopback(tt, form.fallbackTag);
|
||||
}
|
||||
} else {
|
||||
const oldTag = list[editingIndex]?.tag;
|
||||
const oldFallback = list[editingIndex]?.fallbackTag || '';
|
||||
list[editingIndex] = wire;
|
||||
|
||||
if (oldTag && oldTag !== wire.tag) {
|
||||
const rules = tt.routing.rules || [];
|
||||
for (const rule of rules) {
|
||||
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);
|
||||
@@ -185,6 +242,15 @@ export default function BalancersTab({
|
||||
}
|
||||
|
||||
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
|
||||
? observersRemovedByDeletingBalancer(templateSettings, idx)
|
||||
: { observatory: false, burst: false };
|
||||
@@ -199,7 +265,14 @@ export default function BalancersTab({
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
if (tt.routing?.balancers) {
|
||||
const deletedTag = tt.routing.balancers[idx]?.tag;
|
||||
removeBalancerLoopback(tt, deletedTag);
|
||||
tt.routing.balancers.splice(idx, 1);
|
||||
if (tt.routing?.rules) {
|
||||
tt.routing.rules = tt.routing.rules.filter(
|
||||
(r) => r?.balancerTag !== deletedTag,
|
||||
);
|
||||
}
|
||||
syncObservatories(tt);
|
||||
}
|
||||
}),
|
||||
@@ -278,7 +351,7 @@ export default function BalancersTab({
|
||||
</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'),
|
||||
key: 'live',
|
||||
@@ -293,9 +366,13 @@ export default function BalancersTab({
|
||||
</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 (
|
||||
<Tooltip title={(live.selected || []).join(', ') || undefined}>
|
||||
<Tooltip title={tooltipText || undefined}>
|
||||
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -308,6 +385,23 @@ export default function BalancersTab({
|
||||
width: 200,
|
||||
render: (_v, record) => {
|
||||
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 (
|
||||
<Select
|
||||
size="small"
|
||||
@@ -315,8 +409,8 @@ export default function BalancersTab({
|
||||
placeholder={t('pages.xray.balancerOverridePh')}
|
||||
allowClear
|
||||
disabled={!live?.running}
|
||||
value={live?.override || undefined}
|
||||
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
|
||||
value={resolvedOverride}
|
||||
options={options}
|
||||
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
|
||||
/>
|
||||
);
|
||||
@@ -359,6 +453,7 @@ export default function BalancersTab({
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
@@ -385,6 +480,9 @@ export default function BalancersTab({
|
||||
open={modalOpen}
|
||||
balancer={editingBalancer}
|
||||
outboundTags={outboundTags}
|
||||
balancerTags={balancerTags}
|
||||
balancers={balancerObjects}
|
||||
templateSettings={templateSettings}
|
||||
otherTags={otherTags}
|
||||
onClose={() => setModalOpen(false)}
|
||||
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'));
|
||||
return;
|
||||
}
|
||||
if (tagValue.startsWith('_bl_')) {
|
||||
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
|
||||
return;
|
||||
}
|
||||
const isDuplicateTag = (existingTags || []).includes(tagValue)
|
||||
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
||||
if (isDuplicateTag) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import TextModal from '@/components/feedback/TextModal';
|
||||
|
||||
import OutboundFormModal from './OutboundFormModal';
|
||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
@@ -140,7 +141,13 @@ export default function OutboundsTab({
|
||||
[templateSettings?.outbounds],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
outbounds
|
||||
.map((o, i) => ({ ...o, key: i }))
|
||||
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
||||
[outbounds],
|
||||
);
|
||||
|
||||
const dialerProxyTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import RoutingBasic from './RoutingBasic';
|
||||
import RouteTester from './RouteTester';
|
||||
import RuleFormModal from './RuleFormModal';
|
||||
@@ -66,26 +67,31 @@ export default function RoutingTab({
|
||||
|
||||
const rows: RuleRow[] = useMemo(
|
||||
() =>
|
||||
rules.map((rule, idx) => {
|
||||
const r: RuleRow = { key: idx };
|
||||
r.enabled = rule.enabled !== false;
|
||||
r.domain = arrJoin(rule.domain);
|
||||
r.ip = arrJoin(rule.ip);
|
||||
r.port = rule.port;
|
||||
r.sourcePort = rule.sourcePort;
|
||||
r.vlessRoute = rule.vlessRoute;
|
||||
r.network = rule.network;
|
||||
r.sourceIP = arrJoin(rule.sourceIP);
|
||||
r.user = arrJoin(rule.user);
|
||||
r.inboundTag = arrJoin(rule.inboundTag);
|
||||
r.protocol = arrJoin(rule.protocol);
|
||||
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
|
||||
r.attrs = JSON.stringify(rule.attrs, null, 2);
|
||||
}
|
||||
r.outboundTag = rule.outboundTag;
|
||||
r.balancerTag = rule.balancerTag;
|
||||
return r;
|
||||
}),
|
||||
rules
|
||||
.map((rule, idx) => {
|
||||
const r: RuleRow = { key: idx };
|
||||
r.enabled = rule.enabled !== false;
|
||||
r.domain = arrJoin(rule.domain);
|
||||
r.ip = arrJoin(rule.ip);
|
||||
r.port = rule.port;
|
||||
r.sourcePort = rule.sourcePort;
|
||||
r.vlessRoute = rule.vlessRoute;
|
||||
r.network = rule.network;
|
||||
r.sourceIP = arrJoin(rule.sourceIP);
|
||||
r.user = arrJoin(rule.user);
|
||||
r.inboundTag = arrJoin(rule.inboundTag);
|
||||
r.protocol = arrJoin(rule.protocol);
|
||||
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
|
||||
r.attrs = JSON.stringify(rule.attrs, null, 2);
|
||||
}
|
||||
r.outboundTag = rule.outboundTag;
|
||||
r.balancerTag = rule.balancerTag;
|
||||
return r;
|
||||
})
|
||||
.filter((r) => {
|
||||
const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
|
||||
return !inboundTags.some(isBalancerLoopbackTag);
|
||||
}),
|
||||
[rules],
|
||||
);
|
||||
|
||||
@@ -105,7 +111,7 @@ export default function RoutingTab({
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const push = (tag?: string) => {
|
||||
if (!tag || seen.has(tag)) return;
|
||||
if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
};
|
||||
|
||||
@@ -101,7 +101,10 @@ export const RuleFormSchema = z.object({
|
||||
});
|
||||
|
||||
export const BalancerFormSchema = z.object({
|
||||
tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
|
||||
tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired').refine(
|
||||
(val) => !val.startsWith('_bl_'),
|
||||
{ message: 'pages.xray.balancer.reservedPrefix' },
|
||||
),
|
||||
strategy: BalancerStrategyTypeSchema.default('random'),
|
||||
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
|
||||
fallbackTag: z.string().default(''),
|
||||
@@ -111,7 +114,11 @@ export const BalancerFormSchema = z.object({
|
||||
export const OutboundTagSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'pages.xray.outboundTagRequired');
|
||||
.min(1, 'pages.xray.outboundTagRequired')
|
||||
.refine(
|
||||
(val) => !val.startsWith('_bl_'),
|
||||
{ message: 'pages.xray.balancer.reservedPrefix' },
|
||||
);
|
||||
|
||||
export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
|
||||
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||
|
||||
@@ -10,6 +10,9 @@ function renderModal(onConfirm = vi.fn()) {
|
||||
open
|
||||
balancer={null}
|
||||
outboundTags={['proxy', 'direct']}
|
||||
balancerTags={[]}
|
||||
balancers={[]}
|
||||
templateSettings={null}
|
||||
otherTags={['existing']}
|
||||
onClose={() => {}}
|
||||
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
|
||||
// outbound tag; an empty target clears the override.
|
||||
// outbound tag; an empty target clears the override. When target names
|
||||
// another balancer, the override resolves to the loopback outbound that
|
||||
// routes traffic through the target balancer via the routing rules.
|
||||
func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
if !s.IsXrayRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
if target != "" {
|
||||
resolved, err := s.resolveOverrideTarget(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved == "" {
|
||||
return errors.New("balancer fallback target not found in routing rules")
|
||||
}
|
||||
target = resolved
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -918,6 +930,42 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
return s.xrayAPI.SetBalancerTarget(tag, target)
|
||||
}
|
||||
|
||||
// resolveOverrideTarget checks if target names a balancer and, if so,
|
||||
// returns the loopback outbound tag that routes to it through the
|
||||
// routing rules. Returns empty if target is already a concrete outbound.
|
||||
func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
|
||||
template, err := s.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
routing, _ := cfg["routing"].(map[string]any)
|
||||
if routing == nil {
|
||||
return "", nil
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rule["balancerTag"] != target {
|
||||
continue
|
||||
}
|
||||
inboundTags, ok := rule["inboundTag"].([]any)
|
||||
if !ok || len(inboundTags) == 0 {
|
||||
continue
|
||||
}
|
||||
if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") {
|
||||
return lbTag, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// TestRoute asks the running core which outbound its router picks for the
|
||||
// described connection.
|
||||
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
|
||||
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "وسم موازن فريد",
|
||||
"selector": "المحدد",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "اختر موزان أحمال آخر كبديل",
|
||||
"balancerFallbackInfo": "سيتم توجيه حركة المرور عبر: الموزان → Loopback → الخادم → الموزان المستهدف → الاتصال الخارجي. سيؤدي هذا إلى إضافة قفزة إضافية عبر الخادم، مما قد يسبب تأخيرات طفيفة.",
|
||||
"balancerFallbackCycle": "لا يمكن تعيين هذا الموزان كبديل — سيؤدي ذلك إلى إنشاء تبعية دائرية.",
|
||||
"balancerDeleteInUse": "لا يمكن حذف هذا الموزان — يتم استخدامه كبديل لـ: {names}",
|
||||
"expected": "المتوقع",
|
||||
"expectedPlaceholder": "العدد الأمثل للعقد",
|
||||
"maxRtt": "أقصى RTT",
|
||||
"tolerance": "التحمل",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل."
|
||||
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
|
||||
"cycleTooltip": "حلقة: {path} → (العودة إلى {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "المفتاح السري",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "غير متصل",
|
||||
"statusUp": "متصل"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1825,6 +1825,12 @@
|
||||
"tagPlaceholder": "unique balancer tag",
|
||||
"selector": "Selector",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "Select another balancer as fallback",
|
||||
"balancerFallbackInfo": "Traffic will be routed through: Balancer → Loopback → Server → Target Balancer → Outbound. This adds an extra hop through the server, which may introduce slight delays.",
|
||||
"balancerFallbackCycle": "Cannot set this balancer as fallback — it would create a circular dependency.",
|
||||
"balancerDeleteInUse": "Cannot delete this balancer — it is used as fallback by: {names}",
|
||||
"reservedPrefix": "_bl_ prefix is reserved for internal balancer loopback objects",
|
||||
"cycleTooltip": "Cycle: {path} → (back to {start})",
|
||||
"expected": "Expected",
|
||||
"expectedPlaceholder": "optimal node count",
|
||||
"maxRtt": "Max RTT",
|
||||
@@ -2155,4 +2161,4 @@
|
||||
"statusDown": "DOWN",
|
||||
"statusUp": "UP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "etiqueta única de balanceador",
|
||||
"selector": "Selector",
|
||||
"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",
|
||||
"expectedPlaceholder": "número óptimo de nodos",
|
||||
"maxRtt": "Máx. RTT",
|
||||
"tolerance": "Tolerancia",
|
||||
"baselines": "Baselines",
|
||||
"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": {
|
||||
"secretKey": "Llave secreta",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "CAÍDO",
|
||||
"statusUp": "ACTIVO"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "تگ منحصربهفرد بالانسر",
|
||||
"selector": "انتخابگر",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "یک بالانسر دیگر به عنوان پشتیبان انتخاب کنید",
|
||||
"balancerFallbackInfo": "ترافیک از مسیر زیر مسیریابی میشود: بالانسر → Loopback → سرور → بالانسر مقصد → اتصال خروجی. این یک پرش اضافی از طریق سرور اضافه میکند که ممکن است تأخیرهای جزئی ایجاد کند.",
|
||||
"balancerFallbackCycle": "امکان تنظیم این بالانسر به عنوان پشتیبان وجود ندارد — وابستگی دایرهای ایجاد میکند.",
|
||||
"balancerDeleteInUse": "امکان حذف این بالانسر وجود ندارد — به عنوان پشتیبان برای موارد زیر استفاده میشود: {names}",
|
||||
"expected": "مورد انتظار",
|
||||
"expectedPlaceholder": "تعداد نود بهینه",
|
||||
"maxRtt": "حداکثر RTT",
|
||||
"tolerance": "تحمل",
|
||||
"baselines": "خطوط پایه",
|
||||
"costs": "هزینهها",
|
||||
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد."
|
||||
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
|
||||
"cycleTooltip": "حلقه: {path} → (بازگشت به {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "کلید شخصی",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "قطع",
|
||||
"statusUp": "وصل"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "tag balancer unik",
|
||||
"selector": "Selector",
|
||||
"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",
|
||||
"expectedPlaceholder": "jumlah node optimal",
|
||||
"maxRtt": "Maks. RTT",
|
||||
"tolerance": "Toleransi",
|
||||
"baselines": "Baselines",
|
||||
"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": {
|
||||
"secretKey": "Kunci Rahasia",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "MATI",
|
||||
"statusUp": "AKTIF"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "一意のバランサータグ",
|
||||
"selector": "セレクター",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "フォールバックとして別のバランサーを選択してください",
|
||||
"balancerFallbackInfo": "トラフィックは以下のルートで転送されます:バランサー → Loopback → サーバー → ターゲットバランサー → アウトバウンド。これによりサーバーを経由する追加ホップが発生し、多少の遅延が生じる場合があります。",
|
||||
"balancerFallbackCycle": "このバランサーをフォールバックに設定できません — 循環依存が発生します。",
|
||||
"balancerDeleteInUse": "このバランサーを削除できません — 以下のバランサーのフォールバックとして使用されています:{names}",
|
||||
"expected": "期待値",
|
||||
"expectedPlaceholder": "最適ノード数",
|
||||
"maxRtt": "最大 RTT",
|
||||
"tolerance": "許容範囲",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。"
|
||||
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
|
||||
"cycleTooltip": "循環: {path} → ({start} に戻る)"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "シークレットキー",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "ダウン",
|
||||
"statusUp": "アップ"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "tag única do balanceador",
|
||||
"selector": "Seletor",
|
||||
"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",
|
||||
"expectedPlaceholder": "número ótimo de nós",
|
||||
"maxRtt": "Máx. RTT",
|
||||
"tolerance": "Tolerância",
|
||||
"baselines": "Baselines",
|
||||
"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": {
|
||||
"secretKey": "Chave Secreta",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "INATIVO",
|
||||
"statusUp": "ATIVO"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "уникальный тег балансировщика",
|
||||
"selector": "Селектор",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "Выберите другой балансировщик в качестве запасного",
|
||||
"balancerFallbackInfo": "Трафик будет маршрутизирован через: Балансировщик → Loopback → Сервер → Целевой балансировщик → Исходящее соединение. Это добавляет дополнительный хоп через сервер, что может вызвать небольшие задержки.",
|
||||
"balancerFallbackCycle": "Невозможно назначить этот балансировщик запасным — это создаст циклическую зависимость.",
|
||||
"balancerDeleteInUse": "Невозможно удалить этот балансировщик — он используется как запасной для: {names}",
|
||||
"expected": "Ожидаемое",
|
||||
"expectedPlaceholder": "оптимальное число узлов",
|
||||
"maxRtt": "Макс. RTT",
|
||||
"tolerance": "Допуск",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag."
|
||||
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
|
||||
"cycleTooltip": "Цикл: {path} → (обратно к {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Секретный ключ",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "НЕДОСТУПЕН",
|
||||
"statusUp": "РАБОТАЕТ"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "benzersiz dengeleyici etiketi",
|
||||
"selector": "Seçici",
|
||||
"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",
|
||||
"expectedPlaceholder": "optimal düğüm sayısı",
|
||||
"maxRtt": "Maks. RTT",
|
||||
"tolerance": "Tolerans",
|
||||
"baselines": "Baselines",
|
||||
"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": {
|
||||
"secretKey": "Gizli Anahtar",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "ÇEVRİMDIŞI",
|
||||
"statusUp": "ÇEVRİMİÇİ"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "унікальний тег балансувальника",
|
||||
"selector": "Селектор",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "Оберіть інший балансувач як резервний",
|
||||
"balancerFallbackInfo": "Трафік буде маршрутизовано через: Балансувач → Loopback → Сервер → Цільовий балансувач → Вихідне зʼєднання. Це додає додатковий хоп через сервер, що може спричинити невеликі затримки.",
|
||||
"balancerFallbackCycle": "Неможливо призначити цей балансувач резервним — це створить циклічну залежність.",
|
||||
"balancerDeleteInUse": "Неможливо видалити цей балансувач — він використовується як резервний для: {names}",
|
||||
"expected": "Очікуване",
|
||||
"expectedPlaceholder": "оптимальна кількість вузлів",
|
||||
"maxRtt": "Макс. RTT",
|
||||
"tolerance": "Допуск",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag."
|
||||
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
|
||||
"cycleTooltip": "Цикл: {path} → (назад до {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Приватний ключ",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "НЕДОСТУПНО",
|
||||
"statusUp": "ДОСТУПНО"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "tag balancer duy nhất",
|
||||
"selector": "Selector",
|
||||
"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",
|
||||
"expectedPlaceholder": "số node tối ưu",
|
||||
"maxRtt": "RTT tối đa",
|
||||
"tolerance": "Dung sai",
|
||||
"baselines": "Baselines",
|
||||
"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": {
|
||||
"secretKey": "Khoá bí mật",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "NGỪNG HOẠT ĐỘNG",
|
||||
"statusUp": "HOẠT ĐỘNG"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "唯一均衡器标签",
|
||||
"selector": "选择器",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "选择另一个负载均衡器作为备用",
|
||||
"balancerFallbackInfo": "流量将通过以下路径路由:负载均衡器 → Loopback → 服务器 → 目标负载均衡器 → 出站连接。这会增加一个经过服务器的额外跳转,可能会引入轻微延迟。",
|
||||
"balancerFallbackCycle": "无法将此负载均衡器设置为备用 — 这会创建循环依赖。",
|
||||
"balancerDeleteInUse": "无法删除此负载均衡器 — 它被用作以下负载均衡器的备用:{names}",
|
||||
"expected": "期望",
|
||||
"expectedPlaceholder": "最佳节点数",
|
||||
"maxRtt": "最大 RTT",
|
||||
"tolerance": "容差",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。"
|
||||
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
|
||||
"cycleTooltip": "循环: {path} → (回到 {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "密钥",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "断开",
|
||||
"statusUp": "恢复"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1706,13 +1706,18 @@
|
||||
"tagPlaceholder": "唯一均衡器標籤",
|
||||
"selector": "選擇器",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "選擇另一個負載平衡器作為備用",
|
||||
"balancerFallbackInfo": "流量將透過以下路徑路由:負載平衡器 → Loopback → 伺服器 → 目標負載平衡器 → 出站連線。這會增加一個經過伺服器的額外跳轉,可能會引入輕微延遲。",
|
||||
"balancerFallbackCycle": "無法將此負載平衡器設定為備用 — 這會建立循環依賴。",
|
||||
"balancerDeleteInUse": "無法刪除此負載平衡器 — 它被用作以下負載平衡器的備用:{names}",
|
||||
"expected": "期望",
|
||||
"expectedPlaceholder": "最佳節點數",
|
||||
"maxRtt": "最大 RTT",
|
||||
"tolerance": "容差",
|
||||
"baselines": "Baselines",
|
||||
"costs": "Costs",
|
||||
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。"
|
||||
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
|
||||
"cycleTooltip": "循環: {path} → (回到 {start})"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "金鑰",
|
||||
@@ -2152,4 +2157,4 @@
|
||||
"statusDown": "中斷",
|
||||
"statusUp": "恢復"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user