diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 59aabe32f..c33733fe1 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -7364,6 +7364,126 @@ } } }, + "/panel/api/xray/balancerStatus": { + "post": { + "tags": [ + "Xray Settings" + ], + "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.", + "operationId": "post_panel_api_xray_balancerStatus", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/xray/balancerOverride": { + "post": { + "tags": [ + "Xray Settings" + ], + "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.", + "operationId": "post_panel_api_xray_balancerOverride", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/xray/routeTest": { + "post": { + "tags": [ + "Xray Settings" + ], + "summary": "Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.", + "operationId": "post_panel_api_xray_routeTest", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/xray/outbound-subs": { "get": { "tags": [ diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index bef6294c0..dd025a79d 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -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; subscriptionTestStates: Record; @@ -74,7 +73,6 @@ export interface UseXraySettingResult { testAllOutbounds: (mode?: string) => Promise; saveAll: () => Promise; resetToDefault: () => Promise; - restartXray: () => Promise; } type XrayConfigPayload = z.infer; @@ -128,7 +126,6 @@ export function useXraySetting(): UseXraySettingResult { const [clientReverseTags, setClientReverseTags] = useState([]); const [subscriptionOutbounds, setSubscriptionOutbounds] = useState([]); const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState([]); - const [restartResult, setRestartResult] = useState(''); const [outboundTestStates, setOutboundTestStates] = useState>({}); // 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> => { 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, ], ); } diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 84570cbb4..1a6112767 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index d2fb7afd6..a0a1e42f6 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -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('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 ( {messageContextHolder} - {modalContextHolder} @@ -332,18 +315,6 @@ export default function XrayPage() { - - {restartResult && ( - {restartResult}} - > - - - )} diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx index ad624cb05..e1172713a 100644 --- a/frontend/src/pages/xray/balancers/BalancersTab.tsx +++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx @@ -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 = { 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(); - 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>({}); + 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); + } + } 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 ( + + + + ); + } + const picked = live.override || live.selected?.[0] || record.fallbackTag; + return ( + + {picked || '—'} + + ); + }, + }, + { + title: t('pages.xray.balancerOverride'), + key: 'overrideTarget', + align: 'center', + width: 200, + render: (_v, record) => { + const live = liveStatus[record.tag]; + return ( + setDest(e.target.value)} + onPressEnter={run} + allowClear + /> + + + setPort(v)} + /> + + + ({ label: tag, value: tag }))} + /> + + +