mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-25 22:06:09 +00:00
feat: apply inbound/outbound/routing changes live via Xray gRPC API
Add a hot-apply layer that computes a diff between the old and new generated config and applies only the changed parts through the Xray gRPC HandlerService and RoutingService, avoiding a full process restart whenever possible. A restart is still performed when sections that have no reload API (log, dns, policy, observatory, ...) actually change. Key additions: - internal/xray/hot_diff.go: ComputeHotDiff with canonical-JSON comparison (sorted keys, null=absent, full number precision) so UI reformatting never triggers a spurious restart - internal/xray/api.go: AddOutbound/DelOutbound, ApplyRoutingConfig, GetBalancerInfo, SetBalancerTarget, TestRoute gRPC wrappers - internal/web/service/xray.go: tryHotApply, ensureAPIServices, GetBalancersStatus, OverrideBalancer, TestRoute service methods - internal/web/controller/xray_setting.go: balancerStatus, balancerOverride, routeTest API endpoints - frontend: BalancersTab live-status/override columns, RouteTester component, Restart button removed (Save now hot-applies) - balancer-helpers.ts: syncObservatories never creates observatory sections for random/roundRobin balancers (no reload API → restart) - i18n: balancerLive/Override/routeTester keys added to all 13 locales
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpUtil, Msg, PromiseUtil } from '@/utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
@@ -53,7 +53,6 @@ export interface UseXraySettingResult {
|
||||
clientReverseTags: string[];
|
||||
subscriptionOutbounds: unknown[];
|
||||
subscriptionOutboundTags: string[];
|
||||
restartResult: string;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
@@ -74,7 +73,6 @@ export interface UseXraySettingResult {
|
||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||
saveAll: () => Promise<void>;
|
||||
resetToDefault: () => Promise<void>;
|
||||
restartXray: () => Promise<void>;
|
||||
}
|
||||
|
||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||
@@ -128,7 +126,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
|
||||
const [restartResult, setRestartResult] = useState('');
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
||||
// results are keyed by tag rather than by index.
|
||||
@@ -238,18 +235,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
},
|
||||
});
|
||||
|
||||
const restartMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
if (!msg?.success) return msg;
|
||||
await PromiseUtil.sleep(500);
|
||||
const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
|
||||
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
||||
if (validated?.success) setRestartResult(validated.obj || '');
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
const resetDefaultMut = useMutation({
|
||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
||||
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
||||
@@ -265,10 +250,9 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
|
||||
const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
|
||||
const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
|
||||
const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
|
||||
|
||||
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
|
||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
|
||||
// (success or a failure-shaped result); callers store it under their own key.
|
||||
@@ -384,7 +368,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
@@ -397,7 +380,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
}),
|
||||
[
|
||||
fetched,
|
||||
@@ -414,7 +396,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
@@ -427,7 +408,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1045,6 +1045,40 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/balancerStatus',
|
||||
summary: 'Live state of routing balancers in the running core (RoutingService.GetBalancerInfo): current override and the targets the strategy prefers. Returns a map keyed by balancer tag.',
|
||||
params: [
|
||||
{ name: 'tags', in: 'body (form)', type: 'string', desc: 'Comma-separated balancer tags to query (e.g. "b1,b2").' },
|
||||
],
|
||||
body: 'tags=b1,b2',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/balancerOverride',
|
||||
summary: 'Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.',
|
||||
params: [
|
||||
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Balancer tag (required).' },
|
||||
{ name: 'target', in: 'body (form)', type: 'string', desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.' },
|
||||
],
|
||||
body: 'tag=b1&target=proxy',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/routeTest',
|
||||
summary: 'Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.',
|
||||
params: [
|
||||
{ name: 'domain', in: 'body (form)', type: 'string', desc: 'Target domain. Either domain or ip is required.' },
|
||||
{ name: 'ip', in: 'body (form)', type: 'string', desc: 'Target IP. Either domain or ip is required.' },
|
||||
{ name: 'port', in: 'body (form)', type: 'number', desc: 'Target port (optional).' },
|
||||
{ name: 'network', in: 'body (form)', type: 'string', desc: '"tcp" (default) or "udp".' },
|
||||
{ name: 'inboundTag', in: 'body (form)', type: 'string', desc: 'Simulate arrival on this inbound (optional).' },
|
||||
{ name: 'protocol', in: 'body (form)', type: 'string', desc: 'Sniffed protocol such as http, tls, bittorrent (optional).' },
|
||||
{ name: 'email', in: 'body (form)', type: 'string', desc: 'User attribution for user-based rules (optional).' },
|
||||
],
|
||||
body: 'domain=example.com&port=443&network=tcp',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/outbound-subs',
|
||||
|
||||
@@ -10,15 +10,12 @@ import {
|
||||
FloatButton,
|
||||
Layout,
|
||||
message,
|
||||
Modal,
|
||||
Popover,
|
||||
Radio,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -63,7 +60,6 @@ export default function XrayPage() {
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
restartResult,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
@@ -75,10 +71,8 @@ export default function XrayPage() {
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
restartXray,
|
||||
} = xs;
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [warpOpen, setWarpOpen] = useState(false);
|
||||
const [nordOpen, setNordOpen] = useState(false);
|
||||
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
|
||||
@@ -187,16 +181,6 @@ export default function XrayPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
modal.confirm({
|
||||
title: t('pages.xray.restartConfirmTitle'),
|
||||
content: t('pages.xray.restartConfirmContent'),
|
||||
okText: t('pages.xray.restart'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => restartXray(),
|
||||
});
|
||||
}
|
||||
|
||||
function onSaveAll() {
|
||||
try {
|
||||
JSON.parse(xraySetting);
|
||||
@@ -306,7 +290,6 @@ export default function XrayPage() {
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
|
||||
@@ -332,18 +315,6 @@ export default function XrayPage() {
|
||||
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
|
||||
{t('pages.xray.save')}
|
||||
</Button>
|
||||
<Button type="primary" danger disabled={!saveDisabled} onClick={confirmRestart}>
|
||||
{t('pages.xray.restart')}
|
||||
</Button>
|
||||
{restartResult && (
|
||||
<Popover
|
||||
placement="rightTop"
|
||||
title={t('pages.xray.restartOutputTitle')}
|
||||
content={<pre className="restart-result">{restartResult}</pre>}
|
||||
>
|
||||
<QuestionCircleOutlined className="restart-icon" />
|
||||
</Popover>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={10} className="header-info">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Dropdown, Empty, Modal, Radio, Space, Table, Tag } from 'antd';
|
||||
import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
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 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 { HttpUtil } from '@/utils';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
BalancerObject,
|
||||
@@ -14,6 +16,15 @@ import type {
|
||||
BalancerStrategyType,
|
||||
} from '@/schemas/routing';
|
||||
|
||||
// Live state of one balancer inside the running core, as reported by the
|
||||
// panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
|
||||
interface BalancerLiveStatus {
|
||||
tag: string;
|
||||
running: boolean;
|
||||
override: string;
|
||||
selected: string[];
|
||||
}
|
||||
|
||||
interface BalancersTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
@@ -40,53 +51,6 @@ const STRATEGY_LABELS: Record<string, string> = {
|
||||
leastPing: 'Least ping',
|
||||
};
|
||||
|
||||
const DEFAULT_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [] as string[],
|
||||
probeURL: 'https://www.google.com/generate_204',
|
||||
probeInterval: '1m',
|
||||
enableConcurrency: true,
|
||||
});
|
||||
|
||||
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [] as string[],
|
||||
pingConfig: {
|
||||
destination: 'https://www.google.com/generate_204',
|
||||
interval: '1m',
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
},
|
||||
});
|
||||
|
||||
function collectSelectors(list: BalancerRecord[]): string[] {
|
||||
const out = new Set<string>();
|
||||
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function syncObservatories(t: XraySettingsValue) {
|
||||
const balancers = (t.routing?.balancers || []) as BalancerRecord[];
|
||||
|
||||
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 burstFeeders = balancers.filter((b) => {
|
||||
const type = b.strategy?.type || 'random';
|
||||
return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
|
||||
});
|
||||
if (burstFeeders.length > 0) {
|
||||
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(burstFeeders);
|
||||
} else {
|
||||
delete t.burstObservatory;
|
||||
}
|
||||
}
|
||||
|
||||
export default function BalancersTab({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
@@ -143,6 +107,38 @@ export default function BalancersTab({
|
||||
[setTemplateSettings],
|
||||
);
|
||||
|
||||
const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
|
||||
const [liveLoading, setLiveLoading] = useState(false);
|
||||
const liveTags = useMemo(
|
||||
() => rows.map((r) => r.tag).filter(Boolean).join(','),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const refreshLive = useCallback(async () => {
|
||||
if (!liveTags) {
|
||||
setLiveStatus({});
|
||||
return;
|
||||
}
|
||||
setLiveLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/balancerStatus', { tags: liveTags }, { silent: true });
|
||||
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
||||
setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
|
||||
}
|
||||
} finally {
|
||||
setLiveLoading(false);
|
||||
}
|
||||
}, [liveTags]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLive();
|
||||
}, [refreshLive]);
|
||||
|
||||
async function setOverride(tag: string, target: string) {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
|
||||
if (msg?.success) await refreshLive();
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setEditingBalancer(null);
|
||||
setEditingIndex(null);
|
||||
@@ -275,6 +271,49 @@ export default function BalancersTab({
|
||||
)),
|
||||
},
|
||||
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
|
||||
{
|
||||
title: t('pages.xray.balancerLive'),
|
||||
key: 'live',
|
||||
align: 'center',
|
||||
width: 170,
|
||||
render: (_v, record) => {
|
||||
const live = liveStatus[record.tag];
|
||||
if (!live?.running) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.balancerNotRunning')}>
|
||||
<Tag>—</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const picked = live.override || live.selected?.[0] || record.fallbackTag;
|
||||
return (
|
||||
<Tooltip title={(live.selected || []).join(', ') || undefined}>
|
||||
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.balancerOverride'),
|
||||
key: 'overrideTarget',
|
||||
align: 'center',
|
||||
width: 200,
|
||||
render: (_v, record) => {
|
||||
const live = liveStatus[record.tag];
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 170 }}
|
||||
placeholder={t('pages.xray.balancerOverridePh')}
|
||||
allowClear
|
||||
disabled={!live?.running}
|
||||
value={live?.override || undefined}
|
||||
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
|
||||
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hasObservatory = !!templateSettings?.observatory;
|
||||
@@ -321,9 +360,14 @@ export default function BalancersTab({
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Balancers')}
|
||||
</Button>
|
||||
<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}
|
||||
@@ -331,7 +375,7 @@ export default function BalancersTab({
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 400 }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
|
||||
{showObsEditor && (
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import type { BalancerObject } from '@/schemas/routing';
|
||||
|
||||
export const DEFAULT_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [] as string[],
|
||||
probeURL: 'https://www.google.com/generate_204',
|
||||
probeInterval: '1m',
|
||||
enableConcurrency: true,
|
||||
});
|
||||
|
||||
export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
|
||||
subjectSelector: [] as string[],
|
||||
pingConfig: {
|
||||
destination: 'https://www.google.com/generate_204',
|
||||
interval: '1m',
|
||||
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
|
||||
timeout: '5s',
|
||||
sampling: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export function collectSelectors(list: BalancerObject[]): string[] {
|
||||
const out = new Set<string>();
|
||||
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
|
||||
return [...out];
|
||||
}
|
||||
|
||||
// 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 any change here forces a full process
|
||||
// restart — that's why random/roundRobin balancers, which work fine without
|
||||
// an observer, never CREATE one: a plain balancer add/edit then stays a
|
||||
// routing-only change and applies live through the core API. An already
|
||||
// existing burstObservatory is still kept in sync for them (alive-only
|
||||
// filtering keeps working for setups that had it), it's just never the
|
||||
// reason a new one appears.
|
||||
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 required = balancers.filter((b) => b.strategy?.type === 'leastLoad');
|
||||
const optional = balancers.filter((b) => {
|
||||
const type = b.strategy?.type || 'random';
|
||||
return type === 'random' || type === 'roundRobin';
|
||||
});
|
||||
if (required.length > 0 || (optional.length > 0 && t.burstObservatory)) {
|
||||
if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
|
||||
(t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]);
|
||||
} else if (required.length === 0 && optional.length === 0) {
|
||||
delete t.burstObservatory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
|
||||
import { AimOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
interface RouteTesterProps {
|
||||
inboundTags: string[];
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
|
||||
interface RouteTestResult {
|
||||
matched: boolean;
|
||||
outboundTag: string;
|
||||
groupTags?: string[];
|
||||
}
|
||||
|
||||
const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
|
||||
|
||||
export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dest, setDest] = useState('');
|
||||
const [port, setPort] = useState<number | null>(443);
|
||||
const [network, setNetwork] = useState('tcp');
|
||||
const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
|
||||
const [protocol, setProtocol] = useState<string | undefined>(undefined);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<RouteTestResult | null>(null);
|
||||
|
||||
async function run() {
|
||||
const value = dest.trim();
|
||||
if (!value) return;
|
||||
// Domains never contain ':' and a pure dotted-quad is an IPv4 address;
|
||||
// everything else is treated as a domain.
|
||||
const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
|
||||
domain: isIp ? '' : value,
|
||||
ip: isIp ? value : '',
|
||||
port: port ?? 0,
|
||||
network,
|
||||
inboundTag: inboundTag || '',
|
||||
protocol: protocol || '',
|
||||
});
|
||||
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
||||
setResult(msg.obj as RouteTestResult);
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldSpan = isMobile ? 24 : undefined;
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
|
||||
<Row gutter={[8, 8]} align="bottom">
|
||||
<Col xs={fieldSpan} sm={7}>
|
||||
<Input
|
||||
placeholder={t('pages.xray.routeTesterDest')}
|
||||
value={dest}
|
||||
onChange={(e) => setDest(e.target.value)}
|
||||
onPressEnter={run}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={3}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={65535}
|
||||
placeholder={t('pages.xray.routeTesterPort')}
|
||||
value={port}
|
||||
onChange={(v) => setPort(v)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={3}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={network}
|
||||
onChange={setNetwork}
|
||||
options={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.xray.routeTesterInbound')}
|
||||
allowClear
|
||||
value={inboundTag}
|
||||
onChange={setInboundTag}
|
||||
options={inboundTags.filter(Boolean).map((tag) => ({ label: tag, value: tag }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.xray.routeTesterProtocol')}
|
||||
allowClear
|
||||
value={protocol}
|
||||
onChange={setProtocol}
|
||||
options={PROTOCOL_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={fieldSpan} sm={3}>
|
||||
<Button type="primary" icon={<AimOutlined />} loading={testing} disabled={!dest.trim()} onClick={run} block>
|
||||
{t('pages.xray.routeTesterTest')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{result && (
|
||||
result.matched ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={
|
||||
<Space wrap>
|
||||
<span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
|
||||
<Tag color="blue">{result.outboundTag || '—'}</Tag>
|
||||
{(result.groupTags || []).length > 0 && (
|
||||
<>
|
||||
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
|
||||
{(result.groupTags || []).map((tag) => (
|
||||
<Tag key={tag} color="orange">{tag}</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal, Space, Table, Tabs } from 'antd';
|
||||
import { ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { AimOutlined, ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import RoutingBasic from './RoutingBasic';
|
||||
import RouteTester from './RouteTester';
|
||||
import RuleFormModal from './RuleFormModal';
|
||||
import type { RoutingRule } from './RuleFormModal';
|
||||
import RuleCardList from './RuleCardList';
|
||||
@@ -312,6 +313,11 @@ export default function RoutingTab({
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tester',
|
||||
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
|
||||
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RuleFormModal
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
|
||||
return { routing, ...extra } as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
// 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.
|
||||
describe('syncObservatories', () => {
|
||||
it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
expect(t.observatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create burstObservatory for roundRobin', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates burstObservatory for leastLoad (required by the strategy)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('creates observatory for leastPing (required by the strategy)', () => {
|
||||
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)', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('removes observatories when no balancer can use them', () => {
|
||||
const t = tpl({ balancers: [] }, {
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user