diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx index 338b12429..b29e810d0 100644 --- a/frontend/src/pages/xray/balancers/BalancersTab.tsx +++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx @@ -367,7 +367,6 @@ export default function BalancersTab({ ), }, diff --git a/frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx b/frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx index 482608c15..ceb29f2f7 100644 --- a/frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx +++ b/frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd'; +import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd'; import { SettingListItem } from '@/components/ui'; import { @@ -13,11 +13,11 @@ import { type PingConfigObject, } from '@/schemas/observatory'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; +import { settingsRequireBurstObservatory } from './balancer-helpers'; interface ObservatorySettingsTabProps { templateSettings: XraySettingsValue | null; mutate: (mutator: (next: XraySettingsValue) => void) => void; - isMobile: boolean; } const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({}); @@ -43,7 +43,6 @@ function SelectorTags({ tags }: { tags: string[] }) { export default function ObservatorySettingsTab({ templateSettings, mutate, - isMobile, }: ObservatorySettingsTabProps) { const { t } = useTranslation(); @@ -63,13 +62,10 @@ export default function ObservatorySettingsTab({ const hasObservatory = observatory != null; const hasBurst = burst != null; - - const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory'); - const effectiveView = !hasObservatory && hasBurst + const hasMixedObservers = hasObservatory && hasBurst; + const activeView = hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings)) ? 'burstObservatory' - : !hasBurst && hasObservatory - ? 'observatory' - : view; + : 'observatory'; function patchObservatory(patch: Partial) { mutate((tt) => { @@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({ return ( - - {hasObservatory && hasBurst && ( - setView(v as 'observatory' | 'burstObservatory')} - options={[ - { label: t('pages.xray.observatory.title'), value: 'observatory' }, - { label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' }, - ]} + + {hasMixedObservers && ( + )} -
{effectiveView === 'observatory' ? observatorySection : burstSection}
+
{activeView === 'observatory' ? observatorySection : burstSection}
); } diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts index a692f70d2..86dc394a0 100644 --- a/frontend/src/pages/xray/balancers/balancer-helpers.ts +++ b/frontend/src/pages/xray/balancers/balancer-helpers.ts @@ -26,6 +26,16 @@ export function collectSelectors(list: BalancerObject[]): string[] { return [...out]; } +export function balancerRequiresBurstObservatory(b: BalancerObject): boolean { + const type = b.strategy?.type || 'random'; + return type === 'leastLoad' || ((type === 'random' || type === 'roundRobin') && (b.fallbackTag ?? '').length > 0); +} + +export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean { + const balancers = (t?.routing?.balancers || []) as BalancerObject[]; + return balancers.some(balancerRequiresBurstObservatory); +} + // syncObservatories keeps the (burst)observatory sections aligned with the // balancer strategies that actually require them. Observatories have no runtime // reload API in xray-core, so creating OR removing one forces a full process @@ -36,39 +46,35 @@ export function collectSelectors(list: BalancerObject[]): string[] { // when its fallbackTag is set (issue #5605): with a fallbackTag the strategy // calls RequireFeatures(Observatory) and the core aborts startup with "not all // dependencies are resolved" if none exists; without a fallbackTag it never even -// consults an observatory. leastLoad always needs the burst observer, leastPing -// the regular one. +// consults an observatory. leastLoad needs the burst observer, while leastPing +// can use any extension.Observatory result with Alive/Delay. When a burst +// observer is required, keep all observer-backed balancers on burstObservatory +// to avoid xray-core resolving the earlier regular observatory feature instead. // // So each observer lives exactly as long as something requires it, and is // dropped the moment nothing does — clearing the last fallbackTag (or deleting -// the last leastLoad) removes the burst observer again. A no-fallback balancer's -// selector is still probed while the observer exists for another reason, but -// never keeps it alive on its own. +// the last leastLoad) removes the burst observer again. A no-fallback +// Random/RoundRobin balancer never expands the observer either, because those +// strategies do not consume observer data. export function syncObservatories(t: XraySettingsValue) { const balancers = (t.routing?.balancers || []) as BalancerObject[]; const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing'); - if (leastPings.length > 0) { - if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY)); - (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings); - } else { - delete t.observatory; - } - - const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0; - const required = balancers.filter((b) => { - const type = b.strategy?.type || 'random'; - if (type === 'leastLoad') return true; - return (type === 'random' || type === 'roundRobin') && hasFallback(b); - }); - const optional = balancers.filter((b) => { - const type = b.strategy?.type || 'random'; - return (type === 'random' || type === 'roundRobin') && !hasFallback(b); - }); + const required = balancers.filter(balancerRequiresBurstObservatory); if (required.length > 0) { + delete t.observatory; if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); - (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]); + (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([ + ...required, + ...leastPings, + ]); } else { delete t.burstObservatory; + if (leastPings.length > 0) { + if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY)); + (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings); + } else { + delete t.observatory; + } } } diff --git a/frontend/src/test/balancer-observatory-sync.test.ts b/frontend/src/test/balancer-observatory-sync.test.ts index 40cfd4af9..362c67aad 100644 --- a/frontend/src/test/balancer-observatory-sync.test.ts +++ b/frontend/src/test/balancer-observatory-sync.test.ts @@ -7,12 +7,208 @@ function tpl(routing: Record, extra: Record = return { routing, ...extra } as unknown as XraySettingsValue; } +type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory'; + +function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) { + if (expected === 'none') { + expect(t.observatory).toBeUndefined(); + expect(t.burstObservatory).toBeUndefined(); + return; + } + + if (expected === 'observatory') { + expect(t.observatory).toBeDefined(); + expect(t.burstObservatory).toBeUndefined(); + expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors)); + return; + } + + expect(t.observatory).toBeUndefined(); + expect(t.burstObservatory).toBeDefined(); + expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors)); +} + // Observatory sections have no reload API in xray-core, so creating one turns // a balancer save from a live (hot-applied) routing change into a full // restart. These tests pin the rule: only strategies that genuinely need an // observer may create one — which, for random/roundRobin, means a fallbackTag // is set (xray-core then requires the Observatory feature; see #5605). describe('syncObservatories', () => { + it.each([ + { + name: 'random without fallback', + balancers: [{ tag: 'random', selector: ['random-out'] }], + expected: 'none' as const, + selectors: [], + }, + { + name: 'random with fallback', + balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }], + expected: 'burstObservatory' as const, + selectors: ['random-out'], + }, + { + name: 'roundRobin without fallback', + balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }], + expected: 'none' as const, + selectors: [], + }, + { + name: 'roundRobin with fallback', + balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }], + expected: 'burstObservatory' as const, + selectors: ['rr-out'], + }, + { + name: 'leastPing without fallback', + balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }], + expected: 'observatory' as const, + selectors: ['lp-out'], + }, + { + name: 'leastPing with fallback', + balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }], + expected: 'observatory' as const, + selectors: ['lp-out'], + }, + { + name: 'leastLoad without fallback', + balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }], + expected: 'burstObservatory' as const, + selectors: ['ll-out'], + }, + { + name: 'leastLoad with fallback', + balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }], + expected: 'burstObservatory' as const, + selectors: ['ll-out'], + }, + ])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => { + const t = tpl({ balancers }); + syncObservatories(t); + expectObserver(t, expected, selectors); + }); + + it.each([ + { + name: 'random + roundRobin without fallbacks', + balancers: [ + { tag: 'random', selector: ['random-out'] }, + { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }, + ], + expected: 'none' as const, + selectors: [], + }, + { + name: 'leastPing + random without fallback', + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'random', selector: ['random-out'] }, + ], + expected: 'observatory' as const, + selectors: ['lp-out'], + }, + { + name: 'leastPing + roundRobin without fallback', + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }, + ], + expected: 'observatory' as const, + selectors: ['lp-out'], + }, + { + name: 'random fallback + random without fallback', + balancers: [ + { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' }, + { tag: 'random', selector: ['random-out'] }, + ], + expected: 'burstObservatory' as const, + selectors: ['random-fallback-out'], + }, + { + name: 'roundRobin fallback + roundRobin without fallback', + balancers: [ + { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }, + { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }, + ], + expected: 'burstObservatory' as const, + selectors: ['rr-fallback-out'], + }, + { + name: 'leastLoad + random without fallback', + balancers: [ + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + { tag: 'random', selector: ['random-out'] }, + ], + expected: 'burstObservatory' as const, + selectors: ['ll-out'], + }, + { + name: 'leastLoad + roundRobin without fallback', + balancers: [ + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }, + ], + expected: 'burstObservatory' as const, + selectors: ['ll-out'], + }, + { + name: 'leastPing + leastLoad with disjoint selectors', + balancers: [ + { tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } }, + ], + expected: 'burstObservatory' as const, + selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'], + }, + { + name: 'leastPing + random fallback', + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' }, + ], + expected: 'burstObservatory' as const, + selectors: ['lp-out', 'random-fallback-out'], + }, + { + name: 'leastPing + roundRobin fallback', + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }, + ], + expected: 'burstObservatory' as const, + selectors: ['lp-out', 'rr-fallback-out'], + }, + { + name: 'all strategies mixed', + balancers: [ + { tag: 'random', selector: ['random-out'] }, + { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }, + { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' }, + { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }, + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + ], + expected: 'burstObservatory' as const, + selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'], + }, + { + name: 'shared selectors are de-duplicated', + balancers: [ + { tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } }, + { tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' }, + ], + expected: 'burstObservatory' as const, + selectors: ['shared', 'lp-only', 'll-only', 'rf-only'], + }, + ])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => { + const t = tpl({ balancers }); + syncObservatories(t); + expectObserver(t, expected, selectors); + }); + it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] }); syncObservatories(t); @@ -64,7 +260,7 @@ describe('syncObservatories', () => { expect(t.burstObservatory).toBeUndefined(); }); - it('keeps burstObservatory while another fallback balancer still needs it', () => { + it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => { const t = tpl( { balancers: [ @@ -76,7 +272,7 @@ describe('syncObservatories', () => { ); syncObservatories(t); expect(t.burstObservatory).toBeDefined(); - expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']); }); it('creates burstObservatory for leastLoad (required by the strategy)', () => { @@ -86,20 +282,55 @@ describe('syncObservatories', () => { expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); }); - it('creates observatory for leastPing (required by the strategy)', () => { + it('creates observatory for leastPing when no burst observer is required', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] }); syncObservatories(t); expect(t.observatory).toBeDefined(); expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); }); - it('keeps an existing burstObservatory in sync for random balancers (legacy setups)', () => { + it('uses only burstObservatory when leastPing is mixed with leastLoad', () => { + const t = tpl( + { + balancers: [ + { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } }, + ], + }, + { observatory: { subjectSelector: ['stale-least-ping-out'] } }, + ); + syncObservatories(t); + expect(t.observatory).toBeUndefined(); + expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual( + new Set(['least-load-out', 'least-ping-out']), + ); + }); + + it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => { + const t = tpl( + { + balancers: [ + { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } }, + { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' }, + { tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }, + ], + }, + { observatory: { subjectSelector: ['stale-least-ping-out'] } }, + ); + syncObservatories(t); + expect(t.observatory).toBeUndefined(); + expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual( + new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']), + ); + }); + + it('does not keep no-fallback random selectors in an observer created by another balancer', () => { const t = tpl( { balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] }, { burstObservatory: { subjectSelector: ['stale'] } }, ); syncObservatories(t); - expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']); }); it('removes observatories when no balancer can use them', () => { @@ -112,6 +343,30 @@ describe('syncObservatories', () => { expect(t.burstObservatory).toBeUndefined(); }); + it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => { + const t = tpl( + { balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] }, + { + observatory: { subjectSelector: ['stale-observatory-out'] }, + burstObservatory: { subjectSelector: ['stale-burst-out'] }, + }, + ); + syncObservatories(t); + expectObserver(t, 'observatory', ['lp-out']); + }); + + it('switches from stale observatory to burstObservatory when any burst strategy remains', () => { + const t = tpl( + { balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] }, + { + observatory: { subjectSelector: ['stale-observatory-out'] }, + burstObservatory: { subjectSelector: ['stale-burst-out'] }, + }, + ); + syncObservatories(t); + expectObserver(t, 'burstObservatory', ['ll-out']); + }); + it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] }); syncObservatories(t); diff --git a/frontend/src/test/observatory-settings-tab.test.tsx b/frontend/src/test/observatory-settings-tab.test.tsx new file mode 100644 index 000000000..d167c07cc --- /dev/null +++ b/frontend/src/test/observatory-settings-tab.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; + +import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab'; +import type { XraySettingsValue } from '@/hooks/useXraySetting'; +import { renderWithProviders } from './test-utils'; + +function renderTab(templateSettings: XraySettingsValue) { + renderWithProviders( + , + ); +} + +describe('ObservatorySettingsTab', () => { + it('shows one burst settings panel and warns for legacy mixed observers', () => { + renderTab({ + routing: { + balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }], + }, + observatory: { subjectSelector: ['stale-regular'] }, + burstObservatory: { subjectSelector: ['proxy-a'] }, + } as unknown as XraySettingsValue); + + expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy(); + expect(document.querySelector('.ant-segmented')).toBeFalsy(); + expect(screen.getByText('Probe Destination')).toBeTruthy(); + expect(screen.queryByText('Probe URL')).toBeFalsy(); + }); + + it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => { + renderTab({ + routing: { + balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }], + }, + observatory: { subjectSelector: ['proxy-b'] }, + burstObservatory: { subjectSelector: ['stale-burst'] }, + } as unknown as XraySettingsValue); + + expect(screen.getByText(/This config contains both Observatory and Burst Observatory/)).toBeTruthy(); + expect(document.querySelector('.ant-segmented')).toBeFalsy(); + expect(screen.getByText('Probe URL')).toBeTruthy(); + expect(screen.queryByText('Probe Destination')).toBeFalsy(); + }); +}); diff --git a/frontend/src/test/routing-reference-cleanup.test.ts b/frontend/src/test/routing-reference-cleanup.test.ts index 277bd5ea0..9893ddc46 100644 --- a/frontend/src/test/routing-reference-cleanup.test.ts +++ b/frontend/src/test/routing-reference-cleanup.test.ts @@ -83,6 +83,65 @@ describe('outbound deletion', () => { expect(tt.routing!.rules![0].balancerTag).toBeUndefined(); }); + it('cascade-removes the burst observer when deleting an outbound removes the last leastLoad balancer', () => { + const tt = tpl({ + outbounds: [{ tag: 'll-out' }], + routing: { + rules: [], + balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }], + }, + burstObservatory: { subjectSelector: ['ll-out'] }, + }); + const impact = planOutboundDeletion(tt, 0); + expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]); + expect(impact.burst).toBe(true); + applyOutboundDeletion(tt, 0); + expect(tt.burstObservatory).toBeUndefined(); + expect(tt.routing!.balancers).toEqual([]); + }); + + it('cascade-switches from burst to regular observer when only leastPing remains', () => { + const tt = tpl({ + outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }], + routing: { + rules: [], + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + ], + }, + burstObservatory: { subjectSelector: ['lp-out', 'll-out'] }, + }); + const impact = planOutboundDeletion(tt, 1); + expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]); + expect(impact.burst).toBe(true); + applyOutboundDeletion(tt, 1); + expect(tt.burstObservatory).toBeUndefined(); + expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']); + expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]); + }); + + it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => { + const tt = tpl({ + outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }], + routing: { + rules: [], + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + ], + }, + burstObservatory: { subjectSelector: ['lp-out', 'll-out'] }, + }); + const impact = planOutboundDeletion(tt, 0); + expect(impact.balancers).toEqual([{ tag: 'lp', reason: 'selectorEmptied' }]); + expect(impact.burst).toBe(false); + applyOutboundDeletion(tt, 0); + expect(tt.observatory).toBeUndefined(); + expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']); + expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]); + }); + it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => { const tt = tpl({ outbounds: [ @@ -253,6 +312,68 @@ describe('balancer deletion', () => { expect(tt.routing!.balancers).toEqual([]); }); + it('reports and removes the burst observer when deleting the last leastLoad balancer', () => { + const tt = tpl({ + routing: { rules: [], balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }] }, + burstObservatory: { subjectSelector: ['a'] }, + }); + expect(planBalancerDeletion(tt, 0).burst).toBe(true); + applyBalancerDeletion(tt, 0); + expect(tt.burstObservatory).toBeUndefined(); + expect(tt.routing!.balancers).toEqual([]); + }); + + it('reports and removes the burst observer when deleting the last fallback balancer', () => { + const tt = tpl({ + routing: { rules: [], balancers: [{ tag: 'rf', selector: ['a'], fallbackTag: 'direct' }] }, + burstObservatory: { subjectSelector: ['a'] }, + }); + expect(planBalancerDeletion(tt, 0).burst).toBe(true); + applyBalancerDeletion(tt, 0); + expect(tt.burstObservatory).toBeUndefined(); + expect(tt.routing!.balancers).toEqual([]); + }); + + it('switches from burst to regular observer when the deleted balancer was the last burst-required one', () => { + const tt = tpl({ + routing: { + rules: [], + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + ], + }, + burstObservatory: { subjectSelector: ['lp-out', 'll-out'] }, + }); + const impact = planBalancerDeletion(tt, 1); + expect(impact.burst).toBe(true); + expect(impact.observatory).toBe(false); + applyBalancerDeletion(tt, 1); + expect(tt.burstObservatory).toBeUndefined(); + expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']); + expect(tt.routing!.balancers).toEqual([{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }]); + }); + + it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => { + const tt = tpl({ + routing: { + rules: [], + balancers: [ + { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }, + { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }, + ], + }, + burstObservatory: { subjectSelector: ['lp-out', 'll-out'] }, + }); + const impact = planBalancerDeletion(tt, 0); + expect(impact.burst).toBe(false); + expect(impact.observatory).toBe(false); + applyBalancerDeletion(tt, 0); + expect(tt.observatory).toBeUndefined(); + expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['ll-out']); + expect(tt.routing!.balancers).toEqual([{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }]); + }); + it('does not report rules when the deleted balancer is unreferenced', () => { const tt = tpl({ routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] }, diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 7275fa56b..8fb0e6e56 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1692,7 +1692,8 @@ "title": "المرصد", "burstTitle": "مرصد Burst", "autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.", - "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى يتمكن الموازن من قياس الوجهات الصادرة واختيار الأفضل.", + "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى تتمكن الموازنات المعتمدة على المرصد من فحص صحة الوجهات الصادرة قبل اختيار الهدف.", + "mixedLegacy": "يحتوي هذا الإعداد على Observatory و Burst Observatory معًا. يستخدم Xray مرصدًا عالميًا واحدًا، لذلك هذه الحالة القديمة المختلطة غير مدعومة؛ حفظ الموازنات سيحوّلها إلى مرصد واحد.", "subjectSelector": "الوجهات المراقَبة", "subjectSelectorDesc": "وسوم الوجهات الصادرة التي يفحصها هذا المرصد. تتم إدارتها تلقائيًا من الموازنات لديك.", "probeURL": "رابط الفحص (URL)", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 25ef671b9..e6008918b 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1808,7 +1808,8 @@ "title": "Observatory", "burstTitle": "Burst Observatory", "autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.", - "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so the balancer can measure your outbounds and pick the best one.", + "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so observer-backed balancers can check outbound health before choosing a target.", + "mixedLegacy": "This config contains both Observatory and Burst Observatory. Xray uses one global observer, so this mixed legacy state is not supported; saving balancers will normalize it to one observer.", "subjectSelector": "Watched Outbounds", "subjectSelectorDesc": "Outbound tags this observer probes. Managed automatically from your balancers.", "probeURL": "Probe URL", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 9c20198b9..e5b673be4 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1692,7 +1692,8 @@ "title": "Observatorio", "burstTitle": "Observatorio Burst", "autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.", - "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que el balanceador pueda medir tus salidas y elegir la mejor.", + "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que los balanceadores que usan observador puedan comprobar la salud de las salidas antes de elegir un destino.", + "mixedLegacy": "Esta configuración contiene Observatory y Burst Observatory a la vez. Xray usa un único observador global, por lo que este estado mixto heredado no está soportado; al guardar balanceadores se normalizará a un solo observador.", "subjectSelector": "Salidas vigiladas", "subjectSelectorDesc": "Etiquetas de salida que sondea este observador. Se gestionan automáticamente a partir de tus balanceadores.", "probeURL": "URL de sondeo", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4268da570..231c6b320 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1692,7 +1692,8 @@ "title": "رصدخانه", "burstTitle": "رصدخانه Burst", "autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.", - "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسر بتواند خروجی‌ها را اندازه‌گیری کند و بهترین را انتخاب کند.", + "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسرهای متکی به رصدگر بتوانند پیش از انتخاب مقصد، سلامت خروجی‌ها را بررسی کنند.", + "mixedLegacy": "این پیکربندی هم Observatory و هم Burst Observatory دارد. Xray فقط از یک رصدگر سراسری استفاده می‌کند، بنابراین این حالت قدیمیِ ترکیبی پشتیبانی نمی‌شود؛ ذخیرهٔ بالانسرها آن را به یک رصدگر عادی‌سازی می‌کند.", "subjectSelector": "خروجی‌های تحت نظر", "subjectSelectorDesc": "تگ خروجی‌هایی که این رصدگر پروب می‌کند. به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شود.", "probeURL": "آدرس پروب (URL)", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 913b7b854..1f00bae02 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1692,7 +1692,8 @@ "title": "Observatory", "burstTitle": "Burst Observatory", "autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.", - "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer dapat mengukur outbound Anda dan memilih yang terbaik.", + "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer yang memakai observer dapat memeriksa kesehatan outbound sebelum memilih target.", + "mixedLegacy": "Konfigurasi ini berisi Observatory dan Burst Observatory sekaligus. Xray memakai satu observer global, jadi status campuran lama ini tidak didukung; menyimpan balancer akan menormalkannya menjadi satu observer.", "subjectSelector": "Outbound yang Dipantau", "subjectSelectorDesc": "Tag outbound yang di-probe observer ini. Dikelola otomatis dari balancer Anda.", "probeURL": "URL Probe", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index adce49f67..64ef1d49a 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1692,7 +1692,8 @@ "title": "オブザーバトリ", "burstTitle": "バースト オブザーバトリ", "autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。", - "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、バランサーがアウトバウンドを測定して最適なものを選べるようになります。", + "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、オブザーバを使うバランサーがターゲットを選ぶ前にアウトバウンドの健全性を確認できるようになります。", + "mixedLegacy": "この設定には Observatory と Burst Observatory の両方が含まれています。Xray は単一のグローバルオブザーバを使用するため、この古い混在状態はサポートされません。バランサーを保存すると 1 つのオブザーバに正規化されます。", "subjectSelector": "監視対象のアウトバウンド", "subjectSelectorDesc": "このオブザーバがプローブするアウトバウンドのタグ。バランサーから自動的に管理されます。", "probeURL": "プローブ URL", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 2943423de..8106c6dcd 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1692,7 +1692,8 @@ "title": "Observatório", "burstTitle": "Observatório Burst", "autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.", - "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que o balanceador possa medir suas saídas e escolher a melhor.", + "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que balanceadores com observador possam verificar a saúde das saídas antes de escolher um destino.", + "mixedLegacy": "Esta configuração contém Observatory e Burst Observatory ao mesmo tempo. O Xray usa um único observador global, então esse estado misto legado não é suportado; ao salvar os balanceadores ele será normalizado para um único observador.", "subjectSelector": "Saídas monitoradas", "subjectSelectorDesc": "Tags de saída que este observador sonda. Gerenciadas automaticamente a partir dos seus balanceadores.", "probeURL": "URL de sondagem", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index d86822288..9909130d2 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1692,7 +1692,8 @@ "title": "Обсерватория", "burstTitle": "Burst-обсерватория", "autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.", - "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщик мог измерять исходящие и выбирать лучший.", + "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщики с наблюдателем могли проверять состояние исходящих перед выбором цели.", + "mixedLegacy": "В этой конфигурации одновременно есть Observatory и Burst Observatory. Xray использует один глобальный наблюдатель, поэтому такое устаревшее смешанное состояние не поддерживается; сохранение балансировщиков нормализует его до одного наблюдателя.", "subjectSelector": "Отслеживаемые исходящие", "subjectSelectorDesc": "Теги исходящих, которые опрашивает этот наблюдатель. Управляется автоматически на основе ваших балансировщиков.", "probeURL": "URL пробы", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index c59a0db5e..c2b27a561 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1692,7 +1692,8 @@ "title": "Gözlemci", "burstTitle": "Burst Gözlemci", "autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.", - "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece dengeleyici çıkışlarınızı ölçüp en iyisini seçebilir.", + "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece gözlemci kullanan dengeleyiciler hedef seçmeden önce çıkış sağlığını kontrol edebilir.", + "mixedLegacy": "Bu yapılandırmada hem Observatory hem de Burst Observatory var. Xray tek bir global gözlemci kullanır, bu nedenle bu eski karma durum desteklenmez; dengeleyiciler kaydedildiğinde tek bir gözlemciye normalleştirilir.", "subjectSelector": "İzlenen Çıkışlar", "subjectSelectorDesc": "Bu gözlemcinin sınadığı çıkış etiketleri. Dengeleyicilerinize göre otomatik yönetilir.", "probeURL": "Sınama URL'si", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index aa7e785af..76559ad33 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1692,7 +1692,8 @@ "title": "Обсерваторія", "burstTitle": "Burst-обсерваторія", "autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.", - "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальник міг вимірювати вихідні й обирати найкращий.", + "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальники зі спостерігачем могли перевіряти стан вихідних перед вибором цілі.", + "mixedLegacy": "Ця конфігурація містить і Observatory, і Burst Observatory. Xray використовує одного глобального спостерігача, тому такий застарілий змішаний стан не підтримується; збереження балансувальників нормалізує його до одного спостерігача.", "subjectSelector": "Відстежувані вихідні", "subjectSelectorDesc": "Теги вихідних, які опитує цей спостерігач. Керується автоматично на основі ваших балансувальників.", "probeURL": "URL проби", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index b99e2d874..622432c91 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1692,7 +1692,8 @@ "title": "Observatory", "burstTitle": "Burst Observatory", "autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.", - "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để balancer có thể đo các outbound và chọn cái tốt nhất.", + "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để các balancer dùng observer có thể kiểm tra sức khỏe outbound trước khi chọn mục tiêu.", + "mixedLegacy": "Cấu hình này có cả Observatory và Burst Observatory. Xray chỉ dùng một observer toàn cục, nên trạng thái hỗn hợp cũ này không được hỗ trợ; khi lưu balancer, nó sẽ được chuẩn hóa về một observer.", "subjectSelector": "Outbound được theo dõi", "subjectSelectorDesc": "Các thẻ outbound mà observer này dò. Được quản lý tự động từ các balancer của bạn.", "probeURL": "URL dò", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index e0ee5cb7d..e27612d28 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1692,7 +1692,8 @@ "title": "观测器", "burstTitle": "突发观测器", "autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。", - "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便负载均衡器测量各出站并选择最优。", + "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便依赖观测器的负载均衡器在选择目标前检查出站健康状态。", + "mixedLegacy": "此配置同时包含 Observatory 和 Burst Observatory。Xray 只使用一个全局观测器,因此不支持这种旧式混合状态;保存负载均衡器时会将其规范化为单个观测器。", "subjectSelector": "被观测的出站", "subjectSelectorDesc": "该观测器探测的出站标签。根据你的负载均衡器自动管理。", "probeURL": "探测 URL", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 5e5e83efa..5ab884dde 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1692,7 +1692,8 @@ "title": "觀測器", "burstTitle": "突發觀測器", "autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。", - "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓負載平衡器能量測各出站並選出最佳者。", + "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓依賴觀測器的負載平衡器能在選擇目標前檢查出站健康狀態。", + "mixedLegacy": "此設定同時包含 Observatory 與 Burst Observatory。Xray 只使用一個全域觀測器,因此不支援這種舊式混合狀態;儲存負載平衡器時會將其正規化為單一觀測器。", "subjectSelector": "被觀測的出站", "subjectSelectorDesc": "此觀測器探測的出站標籤。會根據你的負載平衡器自動管理。", "probeURL": "探測 URL",