mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-29 22:47:14 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user