mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 12:54:20 +00:00
fix(balancers): keep mixed strategies on one observer (#5674)
* fix(balancers): keep mixed strategies on one observer Xray resolves Observatory and Burst Observatory through the same global observer feature. When any burst-required strategy is present, keep all observer-backed balancer selectors on burstObservatory and remove the regular observatory so mixed leastPing configs cannot generate two competing observer blocks. * test(balancers): cover observer strategy combinations Exercise the observer sync matrix for random, round-robin, leastPing, and leastLoad balancers. Include mixed and stale-observer cases so the panel keeps only the observer type that Xray should consume. * fix(balancers): clarify observer empty state Update the Observatory tab empty hint to describe the actual auto-managed cases. Least Ping, Least Load, and fallback Random or Round-robin balancers now explain why an observer is added before the balancer can choose a target. * fix(balancers): remove mixed observer switch Show only the observer settings panel that matches the current balancer requirements. Legacy configs that still contain both observatory blocks now display a warning instead of a tab switch, since saving balancers normalizes the config back to one global observer. * test(balancers): cover observer cleanup on deletion Add direct balancer deletion and outbound cascade cases for leastLoad, fallback, and mixed leastPing scenarios. These tests pin that the final unneeded observer is removed, burst switches back to regular observatory when only leastPing remains, and burst remains when a burst-required balancer survives.
This commit is contained in:
@@ -367,7 +367,6 @@ export default function BalancersTab({
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={mutate}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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<ObservatoryObject>) {
|
||||
mutate((tt) => {
|
||||
@@ -220,19 +216,15 @@ export default function ObservatorySettingsTab({
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
|
||||
{hasObservatory && hasBurst && (
|
||||
<Segmented
|
||||
block={isMobile}
|
||||
value={effectiveView}
|
||||
onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
|
||||
options={[
|
||||
{ label: t('pages.xray.observatory.title'), value: 'observatory' },
|
||||
{ label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
|
||||
]}
|
||||
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
|
||||
{hasMixedObservers && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('pages.xray.observatory.mixedLegacy')}
|
||||
/>
|
||||
)}
|
||||
<div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,208 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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'] }] },
|
||||
|
||||
Reference in New Issue
Block a user