mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 12:24:20 +00:00
feat(balancers): tabbed Observatory/Burst Observatory form (#5627)
* feat(balancers): tabbed Observatory/Burst form replacing raw JSON Replace the raw JSON editor for the Observatory / Burst Observatory sections with a proper Ant Design form, and split the Balancers page into two sub-tabs: "Balancer Settings" (the existing table) and "Observatory". Observers stay fully auto-managed by balancer strategy through the existing syncObservatories logic: users edit only the tunable probe fields, the subjectSelector is shown read-only since it is derived from the balancers, and deleting the last balancer that needs an observer now warns in the confirm dialog that the observer will be removed too. Overlapping selectors keep an observer alive while any balancer still references it. Also add the previously missing pingConfig.httpMethod field (HEAD/GET) and translations for the new strings across all 13 locales. * refactor(balancers): tighten httpMethod typing and align connectivity default Address automated review feedback on the Observatory form: - Use the ObservatoryHttpMethodSchema enum for pingConfig.httpMethod instead of a free-form z.string(), and drive the HTTP method Select from its options. Removes the previously dead enum export and the duplicate local list, and types the field as 'HEAD' | 'GET'. - Align the schema's connectivity default with DEFAULT_BURST_OBSERVATORY (the hicloud URL) so it matches what burst observers are actually created with. No behavior change.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Dropdown, Empty, Modal, Radio, Select, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip } 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 } from './balancer-helpers';
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
|
||||
import ObservatorySettingsTab from './ObservatorySettingsTab';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
@@ -184,8 +185,15 @@ export default function BalancersTab({
|
||||
}
|
||||
|
||||
function confirmDelete(idx: number) {
|
||||
const removed = templateSettings
|
||||
? observersRemovedByDeletingBalancer(templateSettings, idx)
|
||||
: { observatory: false, burst: false };
|
||||
const warnings: string[] = [];
|
||||
if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
|
||||
if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
|
||||
modal.confirm({
|
||||
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
|
||||
content: warnings.length ? warnings.join(' ') : undefined,
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
@@ -316,92 +324,61 @@ export default function BalancersTab({
|
||||
},
|
||||
];
|
||||
|
||||
const hasObservatory = !!templateSettings?.observatory;
|
||||
const hasBurstObservatory = !!templateSettings?.burstObservatory;
|
||||
const showObsEditor = hasObservatory || hasBurstObservatory;
|
||||
const balancerSettingsTab = (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{rows.length === 0 ? (
|
||||
<Empty description={t('emptyBalancersDesc')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
|
||||
<Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
const [obsView, setObsView] = useState<'observatory' | 'burstObservatory'>('observatory');
|
||||
|
||||
useEffect(() => {
|
||||
if (obsView === 'observatory' && !hasObservatory && hasBurstObservatory) {
|
||||
setObsView('burstObservatory');
|
||||
} else if (obsView === 'burstObservatory' && !hasBurstObservatory && hasObservatory) {
|
||||
setObsView('observatory');
|
||||
}
|
||||
}, [obsView, hasObservatory, hasBurstObservatory]);
|
||||
|
||||
const obsText = useMemo(() => {
|
||||
const src = obsView === 'observatory' ? templateSettings?.observatory : templateSettings?.burstObservatory;
|
||||
return src ? JSON.stringify(src, null, 2) : '';
|
||||
}, [obsView, templateSettings?.observatory, templateSettings?.burstObservatory]);
|
||||
|
||||
function onObsTextChange(next: string) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(next);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
mutate((tt) => {
|
||||
if (obsView === 'observatory') tt.observatory = parsed;
|
||||
else tt.burstObservatory = parsed;
|
||||
});
|
||||
}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{rows.length === 0 ? (
|
||||
<Empty description={t('emptyBalancersDesc')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
|
||||
<Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
|
||||
{showObsEditor && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Radio.Group
|
||||
value={obsView}
|
||||
onChange={(e) => setObsView(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
{hasObservatory && <Radio.Button value="observatory">Observatory</Radio.Button>}
|
||||
{hasBurstObservatory && <Radio.Button value="burstObservatory">Burst Observatory</Radio.Button>}
|
||||
</Radio.Group>
|
||||
<JsonEditor
|
||||
value={obsText}
|
||||
onChange={onObsTextChange}
|
||||
minHeight="220px"
|
||||
maxHeight="480px"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'balancers',
|
||||
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.xray.tabBalancerSettings'), isMobile),
|
||||
children: balancerSettingsTab,
|
||||
},
|
||||
{
|
||||
key: 'observatory',
|
||||
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
|
||||
children: (
|
||||
<ObservatorySettingsTab
|
||||
templateSettings={templateSettings}
|
||||
mutate={mutate}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<BalancerFormModal
|
||||
key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd';
|
||||
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import {
|
||||
BurstObservatorySchema,
|
||||
ObservatoryHttpMethodSchema,
|
||||
ObservatorySchema,
|
||||
type BurstObservatoryObject,
|
||||
type ObservatoryHttpMethod,
|
||||
type ObservatoryObject,
|
||||
type PingConfigObject,
|
||||
} from '@/schemas/observatory';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
interface ObservatorySettingsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
mutate: (mutator: (next: XraySettingsValue) => void) => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
|
||||
const BURST_DEFAULTS = BurstObservatorySchema.parse({});
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function SelectorTags({ tags }: { tags: string[] }) {
|
||||
if (!tags || tags.length === 0) return <Tag>—</Tag>;
|
||||
return (
|
||||
<>
|
||||
{tags.map((sel) => (
|
||||
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4, marginBottom: 4 }}>
|
||||
{sel}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObservatorySettingsTab({
|
||||
templateSettings,
|
||||
mutate,
|
||||
isMobile,
|
||||
}: ObservatorySettingsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const observatory = useMemo<ObservatoryObject | null>(() => {
|
||||
const raw = templateSettings?.observatory;
|
||||
if (raw == null) return null;
|
||||
return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
|
||||
}, [templateSettings?.observatory]);
|
||||
|
||||
const burst = useMemo<BurstObservatoryObject | null>(() => {
|
||||
const raw = templateSettings?.burstObservatory;
|
||||
if (raw == null) return null;
|
||||
const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
|
||||
merged.pingConfig = { ...BURST_DEFAULTS.pingConfig, ...asObject(merged.pingConfig) } as PingConfigObject;
|
||||
return merged;
|
||||
}, [templateSettings?.burstObservatory]);
|
||||
|
||||
const hasObservatory = observatory != null;
|
||||
const hasBurst = burst != null;
|
||||
|
||||
const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
|
||||
const effectiveView = !hasObservatory && hasBurst
|
||||
? 'burstObservatory'
|
||||
: !hasBurst && hasObservatory
|
||||
? 'observatory'
|
||||
: view;
|
||||
|
||||
function patchObservatory(patch: Partial<ObservatoryObject>) {
|
||||
mutate((tt) => {
|
||||
tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
|
||||
});
|
||||
}
|
||||
|
||||
function patchPingConfig(patch: Partial<PingConfigObject>) {
|
||||
mutate((tt) => {
|
||||
const current = asObject(tt.burstObservatory);
|
||||
const currentPing = asObject(current.pingConfig);
|
||||
tt.burstObservatory = {
|
||||
...BURST_DEFAULTS,
|
||||
...current,
|
||||
pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasObservatory && !hasBurst) {
|
||||
return <Empty description={t('pages.xray.observatory.emptyHint')} />;
|
||||
}
|
||||
|
||||
const observatorySection = observatory && (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.subjectSelector')}
|
||||
description={t('pages.xray.observatory.subjectSelectorDesc')}
|
||||
>
|
||||
<SelectorTags tags={observatory.subjectSelector} />
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.probeURL')}
|
||||
description={t('pages.xray.observatory.probeURLDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatory.probeURL}
|
||||
onChange={(e) => patchObservatory({ probeURL: e.target.value })}
|
||||
placeholder="https://www.google.com/generate_204"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.probeInterval')}
|
||||
description={t('pages.xray.observatory.probeIntervalDesc')}
|
||||
>
|
||||
<Input
|
||||
value={observatory.probeInterval}
|
||||
onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
|
||||
placeholder="1m"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.enableConcurrency')}
|
||||
description={t('pages.xray.observatory.enableConcurrencyDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={observatory.enableConcurrency}
|
||||
onChange={(v) => patchObservatory({ enableConcurrency: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const burstSection = burst && (
|
||||
<>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.subjectSelector')}
|
||||
description={t('pages.xray.observatory.subjectSelectorDesc')}
|
||||
>
|
||||
<SelectorTags tags={burst.subjectSelector} />
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.destination')}
|
||||
description={t('pages.xray.observatory.destinationDesc')}
|
||||
>
|
||||
<Input
|
||||
value={burst.pingConfig.destination}
|
||||
onChange={(e) => patchPingConfig({ destination: e.target.value })}
|
||||
placeholder="https://www.google.com/generate_204"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.connectivity')}
|
||||
description={t('pages.xray.observatory.connectivityDesc')}
|
||||
>
|
||||
<Input
|
||||
value={burst.pingConfig.connectivity}
|
||||
allowClear
|
||||
onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
|
||||
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.interval')}
|
||||
description={t('pages.xray.observatory.intervalDesc')}
|
||||
>
|
||||
<Input
|
||||
value={burst.pingConfig.interval}
|
||||
onChange={(e) => patchPingConfig({ interval: e.target.value })}
|
||||
placeholder="1m"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.timeout')}
|
||||
description={t('pages.xray.observatory.timeoutDesc')}
|
||||
>
|
||||
<Input
|
||||
value={burst.pingConfig.timeout}
|
||||
onChange={(e) => patchPingConfig({ timeout: e.target.value })}
|
||||
placeholder="5s"
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.sampling')}
|
||||
description={t('pages.xray.observatory.samplingDesc')}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={burst.pingConfig.sampling}
|
||||
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.xray.observatory.httpMethod')}
|
||||
description={t('pages.xray.observatory.httpMethodDesc')}
|
||||
>
|
||||
<Select<ObservatoryHttpMethod>
|
||||
value={burst.pingConfig.httpMethod}
|
||||
onChange={(v) => patchPingConfig({ httpMethod: v })}
|
||||
options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
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' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
httpMethod: 'HEAD',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,3 +72,19 @@ export function syncObservatories(t: XraySettingsValue) {
|
||||
delete t.burstObservatory;
|
||||
}
|
||||
}
|
||||
|
||||
export function observersRemovedByDeletingBalancer(
|
||||
t: XraySettingsValue,
|
||||
idx: number,
|
||||
): { observatory: boolean; burst: boolean } {
|
||||
const hadObservatory = !!t.observatory;
|
||||
const hadBurst = !!t.burstObservatory;
|
||||
if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
|
||||
const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
|
||||
if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
|
||||
syncObservatories(clone);
|
||||
return {
|
||||
observatory: hadObservatory && !clone.observatory,
|
||||
burst: hadBurst && !clone.burstObservatory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ObservatorySchema = z
|
||||
.object({
|
||||
subjectSelector: z.array(z.string()).default([]),
|
||||
probeURL: z.string().default('https://www.google.com/generate_204'),
|
||||
probeInterval: z.string().default('1m'),
|
||||
enableConcurrency: z.boolean().default(true),
|
||||
})
|
||||
.loose();
|
||||
export type ObservatoryObject = z.infer<typeof ObservatorySchema>;
|
||||
|
||||
export const ObservatoryHttpMethodSchema = z.enum(['HEAD', 'GET']);
|
||||
export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
|
||||
|
||||
export const PingConfigSchema = z
|
||||
.object({
|
||||
destination: z.string().default('https://www.google.com/generate_204'),
|
||||
connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'),
|
||||
interval: z.string().default('1m'),
|
||||
timeout: z.string().default('5s'),
|
||||
sampling: z.number().int().min(1).default(2),
|
||||
httpMethod: ObservatoryHttpMethodSchema.default('HEAD'),
|
||||
})
|
||||
.loose();
|
||||
export type PingConfigObject = z.infer<typeof PingConfigSchema>;
|
||||
|
||||
export const BurstObservatorySchema = z
|
||||
.object({
|
||||
subjectSelector: z.array(z.string()).default([]),
|
||||
pingConfig: PingConfigSchema.default(PingConfigSchema.parse({})),
|
||||
})
|
||||
.loose();
|
||||
export type BurstObservatoryObject = z.infer<typeof BurstObservatorySchema>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
|
||||
@@ -111,4 +111,69 @@ describe('syncObservatories', () => {
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
|
||||
expect(burst.pingConfig.httpMethod).toBe('HEAD');
|
||||
expect(burst.pingConfig.sampling).toBe(2);
|
||||
});
|
||||
|
||||
it('drops only the prefixes no remaining balancer uses (note #2)', () => {
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['prefixA', 'prefixB', 'prefixC']),
|
||||
);
|
||||
(t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
|
||||
syncObservatories(t);
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['prefixC', 'prefixB']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observersRemovedByDeletingBalancer', () => {
|
||||
it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
|
||||
});
|
||||
|
||||
it('keeps the burst observer when another balancer still needs it (overlap)', () => {
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
|
||||
});
|
||||
|
||||
it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
|
||||
});
|
||||
|
||||
it('reports nothing removed when the balancer never had an observer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
|
||||
});
|
||||
|
||||
it('does not mutate the template it inspects', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
const before = JSON.stringify(t);
|
||||
observersRemovedByDeletingBalancer(t, 0);
|
||||
expect(JSON.stringify(t)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user