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:
nima1024m
2026-06-28 15:02:18 +02:00
committed by GitHub
parent 51ffba5961
commit 25a86b9ee2
18 changed files with 807 additions and 86 deletions
@@ -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,
};
}
+34
View File
@@ -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);
});
});